trek 2.2.0 → 2.3.1

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 (126) hide show
  1. checksums.yaml +4 -4
  2. data/.slim-lint.yml +0 -3
  3. data/AGENTS.md +9 -2
  4. data/CHANGELOG.md +73 -0
  5. data/Gemfile +4 -0
  6. data/Gemfile.lock +23 -3
  7. data/Makefile +11 -9
  8. data/README.md +1 -1
  9. data/app/assets/icons/trek/chevron-down.svg +1 -0
  10. data/app/assets/icons/trek/chevron-up.svg +1 -0
  11. data/app/assets/icons/trek/external.svg +1 -0
  12. data/app/assets/icons/trek/theme-auto.svg +1 -0
  13. data/app/assets/icons/trek/theme-dark.svg +1 -0
  14. data/app/assets/icons/trek/theme-light.svg +1 -0
  15. data/app/assets/stylesheets/trek/base.css +13 -0
  16. data/app/components/trek/button_component/button_component.css +9 -0
  17. data/app/components/trek/button_component.rb +13 -1
  18. data/app/components/trek/dashboard/tile_component/tile_component.html.slim +1 -1
  19. data/app/components/trek/dashboard/tile_component.rb +14 -0
  20. data/app/components/trek/dialog_component/dialog_component.html.slim +6 -2
  21. data/app/components/trek/dialog_component/dialog_component.js +62 -2
  22. data/app/components/trek/dropdown_component/dropdown_component.css +69 -0
  23. data/app/components/trek/dropdown_component/dropdown_component.html.slim +31 -0
  24. data/app/components/trek/dropdown_component/dropdown_component.js +114 -0
  25. data/app/components/trek/dropdown_component.rb +34 -0
  26. data/app/components/trek/form/group_component/group_component.css +34 -0
  27. data/app/components/trek/header_component/header_component.css +9 -4
  28. data/app/components/trek/header_component/header_component.html.slim +17 -10
  29. data/app/components/trek/header_component/header_component.js +29 -5
  30. data/app/components/trek/header_component.rb +24 -3
  31. data/app/components/trek/menu_component/menu_component.css +6 -0
  32. data/app/components/trek/menu_component.rb +29 -4
  33. data/app/components/trek/toaster/toast_component.rb +1 -1
  34. data/app/controllers/concerns/trek/authorization.rb +12 -0
  35. data/app/controllers/concerns/trek/filters.rb +18 -1
  36. data/app/helpers/trek/page_path_helper.rb +4 -3
  37. data/app/helpers/trek/theme_helper.rb +16 -1
  38. data/app/models/concerns/trek/phonable.rb +15 -3
  39. data/app/models/concerns/trek/sectionable.rb +5 -0
  40. data/app/models/concerns/trek/users/validations.rb +15 -6
  41. data/app/uploaders/audio_uploader.rb +11 -0
  42. data/app/uploaders/record_image_uploader.rb +3 -0
  43. data/config/locales/trek.en.yml +3 -2
  44. data/config/locales/trek.fr.yml +1 -0
  45. data/docs/.vitepress/config.mjs +20 -1
  46. data/docs/.vitepress/theme/style.css +36 -8
  47. data/docs/changelog.md +9 -0
  48. data/docs/customize.md +18 -2
  49. data/docs/deploy.md +7 -2
  50. data/docs/getting-started.md +1 -1
  51. data/docs/index.md +8 -8
  52. data/docs/public/apple-touch-icon.png +0 -0
  53. data/docs/public/favicon.ico +0 -0
  54. data/docs/public/favicon.png +0 -0
  55. data/docs/public/trek-mark.webp +0 -0
  56. data/docs/reference/components.md +15 -0
  57. data/docs/reference/concerns/phonable.md +27 -1
  58. data/docs/reference/controllers.md +8 -3
  59. data/docs/reference/environment-variables.md +2 -0
  60. data/docs/reference/forms.md +2 -0
  61. data/docs/reference/generators/install.md +4 -1
  62. data/docs/reference/icons.md +1 -1
  63. data/docs/reference/models/tag.md +1 -1
  64. data/docs/reference/models/user.md +6 -4
  65. data/docs/reference/uploaders.md +31 -6
  66. data/docs/what-is-trek.md +1 -1
  67. data/lib/generators/trek/install/admin/routes_generator.rb +7 -1
  68. data/lib/generators/trek/install/appsignal_generator.rb +102 -0
  69. data/lib/generators/trek/install/model_specs_generator.rb +64 -0
  70. data/lib/generators/trek/install/postmark_generator.rb +3 -0
  71. data/lib/generators/trek/install/ruby_generator.rb +1 -1
  72. data/lib/generators/trek/install/scalingo_generator.rb +1 -1
  73. data/lib/generators/trek/install/uploaders_generator.rb +2 -0
  74. data/lib/generators/trek/install_generator.rb +5 -0
  75. data/lib/generators/trek/taxonomies_generator.rb +7 -0
  76. data/lib/generators/trek/templates/.gitlab-ci.yml +1 -1
  77. data/lib/generators/trek/templates/config/appsignal.rb.tt +25 -0
  78. data/lib/generators/trek/templates/controllers/admin/uploads_controller.rb +4 -0
  79. data/lib/generators/trek/templates/controllers/admin/user_password_resets_controller.rb +7 -0
  80. data/lib/generators/trek/templates/controllers/admin/users_controller.rb +4 -1
  81. data/lib/generators/trek/templates/controllers/concerns/appsignal_context.rb +37 -0
  82. data/lib/generators/trek/templates/db/seeds/setup/fragments.rb +3 -0
  83. data/lib/generators/trek/templates/db/seeds/setup/users.rb +6 -0
  84. data/lib/generators/trek/templates/db/seeds/test/cases/page_tree.rb +5 -0
  85. data/lib/generators/trek/templates/initializers/appsignal_context.rb +5 -0
  86. data/lib/generators/trek/templates/locales/scaffold/model.en.yml.tt +1 -1
  87. data/lib/generators/trek/templates/migrations/fragments.rb.erb +1 -1
  88. data/lib/generators/trek/templates/migrations/menu_nodes.rb.erb +0 -1
  89. data/lib/generators/trek/templates/migrations/tag_categories.rb.erb +1 -1
  90. data/lib/generators/trek/templates/migrations/taggings.rb.erb +1 -1
  91. data/lib/generators/trek/templates/migrations/users.rb.erb +2 -1
  92. data/lib/generators/trek/templates/models/tag.rb +6 -1
  93. data/lib/generators/trek/templates/policies/admin/tag_policy.rb +1 -1
  94. data/lib/generators/trek/templates/policies/external_link_policy.rb +1 -1
  95. data/lib/generators/trek/templates/spec/fixtures/files/image.png +0 -0
  96. data/lib/generators/trek/templates/spec/fixtures/files/sound.mp3 +0 -0
  97. data/lib/generators/trek/templates/spec/models/page_spec.rb +24 -0
  98. data/lib/generators/trek/templates/spec/models/user_spec.rb +109 -0
  99. data/lib/generators/trek/templates/spec/policies/admin/external_link_policy_spec.rb +17 -0
  100. data/lib/generators/trek/templates/spec/policies/admin/fragment_policy_spec.rb +33 -0
  101. data/lib/generators/trek/templates/spec/policies/admin/page_policy_spec.rb +39 -0
  102. data/lib/generators/trek/templates/spec/policies/admin/preview_policy_spec.rb +9 -0
  103. data/lib/generators/trek/templates/spec/policies/admin/tag_category_policy_spec.rb +31 -0
  104. data/lib/generators/trek/templates/spec/policies/admin/tag_policy_spec.rb +31 -0
  105. data/lib/generators/trek/templates/spec/policies/admin/user_policy_spec.rb +58 -0
  106. data/lib/generators/trek/templates/spec/requests/admin/fragments_spec.rb +50 -0
  107. data/lib/generators/trek/templates/spec/requests/admin/pages_spec.rb +47 -0
  108. data/lib/generators/trek/templates/spec/requests/admin/uploads_spec.rb +22 -0
  109. data/lib/generators/trek/templates/spec/requests/admin/user_password_resets_spec.rb +78 -0
  110. data/lib/generators/trek/templates/spec/requests/admin/user_sessions_spec.rb +51 -0
  111. data/lib/generators/trek/templates/spec/requests/admin/users_spec.rb +99 -0
  112. data/lib/generators/trek/templates/views/admin/user_password_resets/edit.html.slim +1 -1
  113. data/lib/generators/trek/templates/views/admin/user_password_resets/new.html.slim +1 -1
  114. data/lib/generators/trek/templates/views/admin/user_sessions/new.html.slim +2 -2
  115. data/lib/generators/trek/templates/views/admin/users/theme.turbo_stream.slim +2 -0
  116. data/lib/generators/trek/upgrade/v2_generator.rb +3 -0
  117. data/lib/tasks/trek/attachments.rake +18 -0
  118. data/lib/trek/cached_attachments.rb +53 -0
  119. data/lib/trek/saved_attachments_reload.rb +44 -0
  120. data/lib/trek/version.rb +1 -1
  121. data/lib/trek.rb +4 -0
  122. data/package.json +1 -1
  123. data/postcss.config.js +36 -0
  124. data/trek.gemspec +6 -0
  125. metadata +89 -3
  126. data/app/assets/icons/trek/theme.svg +0 -11
@@ -0,0 +1,33 @@
1
+ require "rails_helper"
2
+
3
+ RSpec.describe Admin::FragmentPolicy do
4
+ def policy(role, record = Fragment)
5
+ described_class.new(record, user: users.new(role:))
6
+ end
7
+
8
+ %w[admin editor].each do |role|
9
+ it "lets #{role}s list, create, edit and delete fragments" do
10
+ expect(%i[index? create? update? destroy?].map { |rule| policy(role).apply(rule) }).to all(be(true))
11
+ end
12
+ end
13
+
14
+ %w[user reader guest].each do |role|
15
+ it "keeps #{role}s away from fragments" do
16
+ expect(%i[index? create? update? destroy?].map { |rule| policy(role).apply(rule) }).to all(be(false))
17
+ end
18
+ end
19
+
20
+ it "lets privileged users edit a fragment" do
21
+ expect(policy("editor").permitted_attributes).to contain_exactly(:content, :key, :namespace, :title)
22
+ end
23
+
24
+ it "lets other roles edit nothing" do
25
+ expect(policy("reader").permitted_attributes).to be_empty
26
+ end
27
+
28
+ it "shows other roles no fragment" do
29
+ Fragment.create!(key: "footer", namespace: "layout", title: "Footer")
30
+
31
+ expect(policy("reader").apply_scope(Fragment.all, type: :active_record_relation)).to be_empty
32
+ end
33
+ end
@@ -0,0 +1,39 @@
1
+ require "rails_helper"
2
+
3
+ RSpec.describe Admin::PagePolicy do
4
+ def policy(role, record = Page)
5
+ described_class.new(record, user: users.new(role:))
6
+ end
7
+
8
+ %w[admin editor].each do |role|
9
+ it "lets #{role}s list, create, edit and delete pages" do
10
+ expect(%i[index? create? update? destroy?].map { |rule| policy(role).apply(rule) }).to all(be(true))
11
+ end
12
+ end
13
+
14
+ %w[user reader guest].each do |role|
15
+ it "keeps #{role}s away from pages" do
16
+ expect(%i[index? create? update? destroy?].map { |rule| policy(role).apply(rule) }).to all(be(false))
17
+ end
18
+ end
19
+
20
+ it "lets privileged users edit a page's content" do
21
+ expect(policy("editor").permitted_attributes).to include(:title, :slug, :description, :content)
22
+ end
23
+
24
+ it "lets other roles edit nothing" do
25
+ expect(policy("reader").permitted_attributes).to be_empty
26
+ end
27
+
28
+ it "shows privileged users every page" do
29
+ seed "cases/page_tree"
30
+
31
+ expect(policy("editor").apply_scope(Page.all, type: :active_record_relation)).to match_array(Page.all)
32
+ end
33
+
34
+ it "shows other roles no page" do
35
+ seed "cases/page_tree"
36
+
37
+ expect(policy("reader").apply_scope(Page.all, type: :active_record_relation)).to be_empty
38
+ end
39
+ end
@@ -0,0 +1,9 @@
1
+ require "rails_helper"
2
+
3
+ RSpec.describe Admin::PreviewPolicy do
4
+ %w[admin editor user reader guest].each do |role|
5
+ it "lets #{role}s preview the home page" do
6
+ expect(described_class.new(user: users.new(role:)).apply(:home?)).to be(true)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,31 @@
1
+ require "rails_helper"
2
+
3
+ RSpec.describe Admin::TagCategoryPolicy do
4
+ let(:category) { TagCategory.create!(name: "Topics", key: "topics") }
5
+
6
+ def policy(role, record = TagCategory)
7
+ described_class.new(record, user: users.new(role:))
8
+ end
9
+
10
+ %w[admin editor].each do |role|
11
+ it "lets #{role}s list, create and edit tag categories" do
12
+ expect(%i[index? create? update?].map { |rule| policy(role).apply(rule) }).to all(be(true))
13
+ end
14
+ end
15
+
16
+ %w[user reader guest].each do |role|
17
+ it "keeps #{role}s away from tag categories" do
18
+ expect(%i[index? create? update?].map { |rule| policy(role).apply(rule) }).to all(be(false))
19
+ end
20
+ end
21
+
22
+ it "lets privileged users delete an empty category" do
23
+ expect(policy("editor", category).apply(:destroy?)).to be(true)
24
+ end
25
+
26
+ it "keeps a category with tags from being deleted" do
27
+ Tag.create!(name: "News", key: "news", category:)
28
+
29
+ expect(policy("admin", category.reload).apply(:destroy?)).to be(false)
30
+ end
31
+ end
@@ -0,0 +1,31 @@
1
+ require "rails_helper"
2
+
3
+ RSpec.describe Admin::TagPolicy do
4
+ let(:tag) { Tag.create!(name: "News", key: "news") }
5
+
6
+ def policy(role, record = Tag)
7
+ described_class.new(record, user: users.new(role:))
8
+ end
9
+
10
+ %w[admin editor].each do |role|
11
+ it "lets #{role}s list, create and edit tags" do
12
+ expect(%i[index? create? update?].map { |rule| policy(role).apply(rule) }).to all(be(true))
13
+ end
14
+ end
15
+
16
+ %w[user reader guest].each do |role|
17
+ it "keeps #{role}s away from tags" do
18
+ expect(%i[index? create? update?].map { |rule| policy(role).apply(rule) }).to all(be(false))
19
+ end
20
+ end
21
+
22
+ it "lets privileged users delete an unused tag" do
23
+ expect(policy("editor", tag).apply(:destroy?)).to be(true)
24
+ end
25
+
26
+ it "keeps a tag in use from being deleted" do
27
+ allow(tag).to receive(:taggings).and_return([Tagging.new])
28
+
29
+ expect(policy("admin", tag).apply(:destroy?)).to be(false)
30
+ end
31
+ end
@@ -0,0 +1,58 @@
1
+ require "rails_helper"
2
+
3
+ RSpec.describe Admin::UserPolicy do
4
+ let(:admin) { users.create(role: :admin) }
5
+ let(:editor) { users.create(role: :editor) }
6
+
7
+ def policy(user, record = User)
8
+ described_class.new(record, user:)
9
+ end
10
+
11
+ it "lets admins list, create and delete users" do
12
+ expect(%i[index? create? destroy?].map { |rule| policy(admin).apply(rule) }).to all(be(true))
13
+ end
14
+
15
+ it "lets admins edit any user" do
16
+ expect(policy(admin, editor).apply(:update?)).to be(true)
17
+ end
18
+
19
+ it "keeps editors away from the users list" do
20
+ expect(%i[index? create? destroy?].map { |rule| policy(editor).apply(rule) }).to all(be(false))
21
+ end
22
+
23
+ it "lets editors see and edit themselves" do
24
+ expect(%i[show? update?].map { |rule| policy(editor, editor).apply(rule) }).to all(be(true))
25
+ end
26
+
27
+ it "keeps editors away from other users" do
28
+ expect(%i[show? update?].map { |rule| policy(editor, admin).apply(rule) }).to all(be(false))
29
+ end
30
+
31
+ it "lets admins change a user's role" do
32
+ expect(policy(admin, editor).permitted_attributes).to include(:role)
33
+ end
34
+
35
+ it "lets admins invite the users they create" do
36
+ expect(policy(admin, User).permitted_attributes).to include(:send_invite)
37
+ end
38
+
39
+ it "lets editors change their own details but not their role" do
40
+ expect(policy(editor, editor).permitted_attributes).to contain_exactly(:name, :email, :locale, :theme)
41
+ end
42
+
43
+ it "lets editors change nothing on another user" do
44
+ expect(policy(editor, admin).permitted_attributes).to be_empty
45
+ end
46
+
47
+ it "shows admins the privileged users only" do
48
+ reader = users.create(role: :reader)
49
+
50
+ expect(described_class.new(user: admin).apply_scope(User.all, type: :active_record_relation)).not_to include(reader)
51
+ end
52
+
53
+ it "shows editors no user" do
54
+ admin
55
+
56
+ expect(described_class.new(user: editor).apply_scope(User.all, type: :active_record_relation)).to be_empty
57
+ end
58
+ end
@@ -0,0 +1,50 @@
1
+ require "rails_helper"
2
+
3
+ RSpec.describe "Admin fragments", type: :request do
4
+ let!(:footer) { fragments.create(key: "footer", namespace: "layout", title: "Footer") }
5
+
6
+ before do
7
+ editor = users.create(role: :editor)
8
+ post admin_user_session_path, params: { email: editor.email, password: users.password }
9
+ end
10
+
11
+ it "lists the fragments" do
12
+ get admin_fragments_path
13
+
14
+ expect(response.body).to include("footer")
15
+ end
16
+
17
+ it "renders the new fragment form" do
18
+ get new_admin_fragment_path
19
+
20
+ expect(response).to have_http_status(:ok)
21
+ end
22
+
23
+ it "creates a fragment" do
24
+ params = { fragment: { key: "tagline", namespace: "home", title: "Tagline" } }
25
+
26
+ expect { post admin_fragments_path, params: }.to change(Fragment, :count).by(1)
27
+ end
28
+
29
+ it "refuses a key already used in the namespace" do
30
+ post admin_fragments_path, params: { fragment: { key: "footer", namespace: "layout", title: "Another footer" } }
31
+
32
+ expect(response).to have_http_status(:unprocessable_entity)
33
+ end
34
+
35
+ it "renders the edit form" do
36
+ get edit_admin_fragment_path(footer)
37
+
38
+ expect(response).to have_http_status(:ok)
39
+ end
40
+
41
+ it "updates a fragment" do
42
+ patch admin_fragment_path(footer), params: { fragment: { title: "Site footer" } }
43
+
44
+ expect(footer.reload.title).to eq("Site footer")
45
+ end
46
+
47
+ it "deletes a fragment" do
48
+ expect { delete admin_fragment_path(footer) }.to change(Fragment, :count).by(-1)
49
+ end
50
+ end
@@ -0,0 +1,47 @@
1
+ require "rails_helper"
2
+
3
+ RSpec.describe "Admin pages", type: :request do
4
+ before do
5
+ seed "cases/page_tree"
6
+ editor = users.create(role: :editor)
7
+ post admin_user_session_path, params: { email: editor.email, password: users.password }
8
+ end
9
+
10
+ it "lists the pages" do
11
+ get admin_pages_path
12
+
13
+ expect(response.body).to include("Company").and include("Our team")
14
+ end
15
+
16
+ it "renders the new page form" do
17
+ get new_admin_page_path
18
+
19
+ expect(response).to have_http_status(:ok)
20
+ end
21
+
22
+ it "creates a page" do
23
+ expect { post admin_pages_path, params: { page: { title: "Contact" } } }.to change(Page, :count).by(1)
24
+ end
25
+
26
+ it "refuses a page without a title" do
27
+ post admin_pages_path, params: { page: { title: "" } }
28
+
29
+ expect(response).to have_http_status(:unprocessable_entity)
30
+ end
31
+
32
+ it "renders the edit form" do
33
+ get edit_admin_page_path(pages.team)
34
+
35
+ expect(response).to have_http_status(:ok)
36
+ end
37
+
38
+ it "updates a page" do
39
+ patch admin_page_path(pages.team), params: { page: { title: "The team" } }
40
+
41
+ expect(pages.team.title).to eq("The team")
42
+ end
43
+
44
+ it "deletes a page" do
45
+ expect { delete admin_page_path(pages.team) }.to change(Page, :count).by(-1)
46
+ end
47
+ end
@@ -0,0 +1,22 @@
1
+ require "rails_helper"
2
+
3
+ # The endpoints the admin's image and sound fields upload to (`Trek::Form::ImageFieldComponent`,
4
+ # `Trek::Form::SoundFieldComponent`).
5
+ RSpec.describe "Admin uploads", type: :request do
6
+ before do
7
+ editor = users.create(role: :editor)
8
+ post admin_user_session_path, params: { email: editor.email, password: users.password }
9
+ end
10
+
11
+ it "stores an uploaded image in the cache and returns its data" do
12
+ post "/admin/upload/image", params: { file: fixture_file_upload("image.png", "image/png") }
13
+
14
+ expect(response.parsed_body["data"]).to include("id", "storage" => "cache")
15
+ end
16
+
17
+ it "stores an uploaded sound in the cache and returns its data" do
18
+ post "/admin/upload/audio", params: { file: fixture_file_upload("sound.mp3", "audio/mpeg") }
19
+
20
+ expect(response.parsed_body["data"]).to include("id", "storage" => "cache")
21
+ end
22
+ end
@@ -0,0 +1,78 @@
1
+ require "rails_helper"
2
+
3
+ RSpec.describe "Admin password resets", type: :request do
4
+ let(:editor) { users.create(role: :editor) }
5
+
6
+ it "renders the reset request form" do
7
+ get new_admin_user_password_reset_path
8
+
9
+ expect(response).to have_http_status(:ok)
10
+ end
11
+
12
+ 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)
15
+ end
16
+
17
+ 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
20
+ end
21
+
22
+ it "answers an unknown email as a known one, so it can't be used to find accounts" do
23
+ post admin_user_password_resets_path, params: { email: "nobody@example.com" }
24
+
25
+ expect(response).to redirect_to(new_admin_user_session_path)
26
+ end
27
+
28
+ it "renders the new password form from a reset link" do
29
+ get edit_admin_user_password_reset_path(editor.password_reset_token)
30
+
31
+ expect(response).to have_http_status(:ok)
32
+ end
33
+
34
+ it "renders the new password form from an invitation link" do
35
+ get edit_admin_user_password_reset_path(editor.generate_token_for(:invite))
36
+
37
+ expect(response).to have_http_status(:ok)
38
+ end
39
+
40
+ it "sends an invalid link back to the reset request form" do
41
+ get edit_admin_user_password_reset_path("not-a-token")
42
+
43
+ expect(response).to redirect_to(new_admin_user_password_reset_path)
44
+ end
45
+
46
+ it "sets the new password" do
47
+ patch admin_user_password_reset_path(editor.password_reset_token), params: { password: "a brand new password" }
48
+
49
+ expect(editor.reload.authenticate("a brand new password")).to be_truthy
50
+ end
51
+
52
+ it "signs the user out everywhere" do
53
+ post admin_user_session_path, params: { email: editor.email, password: users.password }
54
+ patch admin_user_password_reset_path(editor.password_reset_token), params: { password: "a brand new password" }
55
+
56
+ expect(editor.user_sessions).to be_empty
57
+ end
58
+
59
+ it "sends an empty password back to the form rather than pretending to change it" do
60
+ token = editor.password_reset_token
61
+ patch admin_user_password_reset_path(token), params: { password: "" }
62
+
63
+ expect(response).to redirect_to(edit_admin_user_password_reset_path(token))
64
+ end
65
+
66
+ it "sends a password shorter than 8 characters back to the form" do
67
+ token = editor.password_reset_token
68
+ patch admin_user_password_reset_path(token), params: { password: "short" }
69
+
70
+ expect(response).to redirect_to(edit_admin_user_password_reset_path(token))
71
+ end
72
+
73
+ it "keeps the old password when the new one is too short" do
74
+ patch admin_user_password_reset_path(editor.password_reset_token), params: { password: "short" }
75
+
76
+ expect(editor.reload.authenticate(users.password)).to be_truthy
77
+ end
78
+ end
@@ -0,0 +1,51 @@
1
+ require "rails_helper"
2
+
3
+ RSpec.describe "Admin sign-in", type: :request do
4
+ let(:editor) { users.create(role: :editor) }
5
+
6
+ it "renders the sign-in form" do
7
+ get new_admin_user_session_path
8
+
9
+ expect(response).to have_http_status(:ok)
10
+ end
11
+
12
+ it "signs a privileged user in and opens the dashboard" do
13
+ post admin_user_session_path, params: { email: editor.email, password: users.password }
14
+ follow_redirect!
15
+
16
+ expect(response).to have_http_status(:ok)
17
+ end
18
+
19
+ it "signs in whatever the case of the email" do
20
+ post admin_user_session_path, params: { email: editor.email.upcase, password: users.password }
21
+
22
+ expect(response).to redirect_to(admin_root_url)
23
+ end
24
+
25
+ it "refuses a wrong password" do
26
+ post admin_user_session_path, params: { email: editor.email, password: "not the password" }
27
+
28
+ expect(response).to redirect_to(new_admin_user_session_path)
29
+ end
30
+
31
+ it "refuses a user who is not privileged" do
32
+ reader = users.create(role: :reader)
33
+ post admin_user_session_path, params: { email: reader.email, password: users.password }
34
+
35
+ expect(response).to redirect_to(new_admin_user_session_path)
36
+ end
37
+
38
+ it "sends a signed-out visitor to the sign-in form" do
39
+ get admin_root_path
40
+
41
+ expect(response).to redirect_to(new_admin_user_session_path)
42
+ end
43
+
44
+ it "signs out" do
45
+ post admin_user_session_path, params: { email: editor.email, password: users.password }
46
+ delete admin_user_session_path
47
+ get admin_root_path
48
+
49
+ expect(response).to redirect_to(new_admin_user_session_path)
50
+ end
51
+ end
@@ -0,0 +1,99 @@
1
+ require "rails_helper"
2
+
3
+ RSpec.describe "Admin users", type: :request do
4
+ let(:admin) { users.create(role: :admin, name: "Ada") }
5
+ let(:editor) { users.create(role: :editor, name: "Grace") }
6
+
7
+ def sign_in(user)
8
+ post admin_user_session_path, params: { email: user.email, password: users.password }
9
+ end
10
+
11
+ context "when signed in as an admin" do
12
+ before { sign_in(admin) }
13
+
14
+ it "lists the privileged users" do
15
+ editor
16
+ get admin_users_path
17
+
18
+ expect(response.body).to include("Grace")
19
+ end
20
+
21
+ it "leaves the other users out" do
22
+ users.create(role: :reader, name: "Linus")
23
+ get admin_users_path
24
+
25
+ expect(response.body).not_to include("Linus")
26
+ end
27
+
28
+ it "renders the new user form" do
29
+ get new_admin_user_path
30
+
31
+ expect(response).to have_http_status(:ok)
32
+ end
33
+
34
+ it "creates a user and sends them an invitation" do
35
+ params = { user: { email: "new@example.com", role: "editor", send_invite: "1" } }
36
+
37
+ expect { post admin_users_path, params: }
38
+ .to change(User, :count).by(1).and have_enqueued_mail(AdminMailer, :invite)
39
+ end
40
+
41
+ it "changes a user's role" do
42
+ patch admin_user_path(editor), params: { user: { role: "admin" } }
43
+
44
+ expect(editor.reload.role).to eq("admin")
45
+ end
46
+
47
+ it "deletes a user" do
48
+ editor
49
+
50
+ expect { delete admin_user_path(editor) }.to change(User, :count).by(-1)
51
+ end
52
+ end
53
+
54
+ context "when signed in as an editor" do
55
+ before { sign_in(editor) }
56
+
57
+ it "is sent back to the dashboard from the users list" do
58
+ get admin_users_path
59
+
60
+ expect(response).to redirect_to(admin_root_path)
61
+ end
62
+
63
+ it "is sent back to the dashboard from another user's profile" do
64
+ get admin_user_path(admin)
65
+
66
+ expect(response).to redirect_to(admin_root_path)
67
+ end
68
+
69
+ it "is told why they were sent back" do
70
+ get admin_users_path
71
+
72
+ expect(flash[:alert]).to eq(I18n.t!("notices.unauthorized"))
73
+ end
74
+
75
+ it "gets a 403 for a JSON request they aren't allowed" do
76
+ get admin_users_path(format: :json)
77
+
78
+ expect(response).to have_http_status(:forbidden)
79
+ end
80
+
81
+ it "edits their own profile" do
82
+ patch admin_user_path(editor), params: { user: { name: "Grace H." } }
83
+
84
+ expect(editor.reload.name).to eq("Grace H.")
85
+ end
86
+
87
+ it "cannot change their own role" do
88
+ patch admin_user_path(editor), params: { user: { role: "admin" } }
89
+
90
+ expect(editor.reload.role).to eq("editor")
91
+ end
92
+
93
+ it "switches their admin theme" do
94
+ patch theme_admin_users_path(format: :turbo_stream), params: { theme: "dark" }
95
+
96
+ expect(editor.reload.theme).to eq("dark")
97
+ end
98
+ end
99
+ end
@@ -1,6 +1,6 @@
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
3
+ = f.group :password, model_name: User.model_name
4
4
  = f.password_field :password,
5
5
  required: true,
6
6
  autocomplete: "new-password",
@@ -1,6 +1,6 @@
1
1
  = render Trek::GateComponent.new
2
2
  = form_with url: admin_user_password_resets_path do |f|
3
- = f.group :email
3
+ = f.group :email, model_name: User.model_name
4
4
  = f.email_field :email,
5
5
  required: true,
6
6
  autofocus: true,
@@ -1,13 +1,13 @@
1
1
  = render Trek::GateComponent.new
2
2
  = form_with url: admin_user_session_path do |f|
3
- = f.group :email
3
+ = f.group :email, model_name: User.model_name
4
4
  = f.email_field :email,
5
5
  required: true,
6
6
  autofocus: @email.nil?,
7
7
  autocomplete: "username",
8
8
  value: @email
9
9
 
10
- = f.group :password
10
+ = f.group :password, model_name: User.model_name
11
11
  = f.password_field :password,
12
12
  required: true,
13
13
  autofocus: @email.present?,
@@ -0,0 +1,2 @@
1
+ = turbo_stream.replace Trek::ThemeHelper::THEME_ICON_ID do
2
+ = theme_icon(Current.user.theme)
@@ -60,6 +60,9 @@ module Trek
60
60
  content.sub!(/^#{key}:\n/, "") if YAML.safe_load(content)&.fetch(key, :absent).nil?
61
61
  end
62
62
  content.gsub!(/\n{3,}/, "\n\n")
63
+ # No blank line left at the end where a removed block was: `preapprove_npm_package` appends
64
+ # its own, and a second run would otherwise collapse the double blank line it makes.
65
+ content.sub!(/\n+\z/, "\n")
63
66
 
64
67
  create_file ".yarnrc.yml", content, force: true
65
68
  rescue Psych::Exception
@@ -0,0 +1,18 @@
1
+ namespace :trek do
2
+ namespace :attachments do
3
+ desc "Promote Shrine attachments left in the cache storage (DRY_RUN=1 only lists them)"
4
+ task promote_cached: :environment do
5
+ # Loads every model, so that all attachments are found
6
+ Rails.application.eager_load!
7
+
8
+ statuses = Hash.new(0)
9
+
10
+ Trek::CachedAttachments.new.promote(dry_run: ENV["DRY_RUN"].present?) do |entry|
11
+ statuses[entry.status] += 1
12
+ puts [entry.status, "#{entry.record.class.name}##{entry.record.id}.#{entry.name}", entry.file.id].join("\t")
13
+ end
14
+
15
+ puts statuses.empty? ? "No attachment left in the cache storage." : statuses.map { "#{_2} #{_1}" }.join(", ")
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,53 @@
1
+ module Trek
2
+ # Finds the records whose Shrine attachments still point to the temporary cache storage —
3
+ # files whose promotion was skipped — and promotes those whose cached file still exists.
4
+ class CachedAttachments
5
+ Entry = Struct.new(:record, :name, :file, :status, keyword_init: true)
6
+
7
+ def self.default_models
8
+ ActiveRecord::Base.descendants.select do |model|
9
+ !model.abstract_class? && model == model.base_class && model.table_exists?
10
+ end
11
+ end
12
+
13
+ def initialize(models = self.class.default_models)
14
+ @models = models
15
+ end
16
+
17
+ # Yields an Entry per cached attachment, whose status is :promoted, :promotable (dry run),
18
+ # :missing (the cached file is gone) or :changed (the record changed during promotion).
19
+ def promote(dry_run: false)
20
+ return enum_for(:promote, dry_run:) unless block_given?
21
+
22
+ each_cached_attachment do |record, name|
23
+ attacher = record.public_send(:"#{name}_attacher")
24
+
25
+ yield Entry.new(record:, name:, file: attacher.file, status: promote_attacher(attacher, dry_run:))
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def promote_attacher(attacher, dry_run:)
32
+ return :missing unless attacher.file.exists?
33
+ return :promotable if dry_run
34
+
35
+ attacher.atomic_promote
36
+ :promoted
37
+ rescue Shrine::AttachmentChanged
38
+ :changed
39
+ end
40
+
41
+ def each_cached_attachment(&)
42
+ @models.each do |model|
43
+ model.ancestors.grep(Shrine::Attachment).each do |attachment|
44
+ name = attachment.attachment_name
45
+ column = model.connection.quote_column_name(:"#{name}_data")
46
+ cache_key = attachment.options.fetch(:cache, :cache).to_s
47
+
48
+ model.where("#{column}::jsonb ->> 'storage' = ?", cache_key).find_each { |record| yield record, name }
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end