moduler 0.2.0 → 0.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 (125) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/moduler/tailwind.css +2103 -1
  3. data/app/assets/javascripts/moduler/application.js +1 -0
  4. data/app/assets/javascripts/moduler/controllers/flash_controller.js +19 -0
  5. data/app/assets/javascripts/moduler/controllers/wysiwyg_controller.js +126 -0
  6. data/app/assets/javascripts/moduler/vendor/marked.esm.js +77 -0
  7. data/app/assets/stylesheets/moduler/tailwind.css +3 -0
  8. data/app/components/moduler/administration/page_component.html.erb +7 -0
  9. data/app/components/moduler/administration/page_component.rb +6 -0
  10. data/app/components/moduler/badge_component.html.erb +8 -0
  11. data/app/components/moduler/badge_component.rb +10 -0
  12. data/app/components/moduler/button_component.html.erb +7 -0
  13. data/app/components/moduler/button_component.rb +19 -0
  14. data/app/components/moduler/button_link_component.html.erb +5 -0
  15. data/app/components/moduler/button_link_component.rb +15 -0
  16. data/app/components/moduler/filter_component.html.erb +12 -0
  17. data/app/components/moduler/filter_component.rb +15 -0
  18. data/app/components/moduler/form/base_error_component.html.erb +1 -0
  19. data/app/components/moduler/form/base_error_component.rb +9 -0
  20. data/app/components/moduler/form/email_component.html.erb +14 -0
  21. data/app/components/moduler/form/email_component.rb +11 -0
  22. data/app/components/moduler/form/password_component.html.erb +14 -0
  23. data/app/components/moduler/form/password_component.rb +11 -0
  24. data/app/components/moduler/form/section_component.html.erb +5 -0
  25. data/app/components/moduler/form/section_component.rb +14 -0
  26. data/app/components/moduler/form/select_component.html.erb +14 -0
  27. data/app/components/moduler/form/select_component.rb +12 -0
  28. data/app/components/moduler/form/submit_component.html.erb +3 -0
  29. data/app/components/moduler/form/submit_component.rb +10 -0
  30. data/app/components/moduler/form/text_component.html.erb +14 -0
  31. data/app/components/moduler/form/text_component.rb +11 -0
  32. data/app/components/moduler/form/textarea_component.html.erb +14 -0
  33. data/app/components/moduler/form/textarea_component.rb +11 -0
  34. data/app/components/moduler/form/wysiwyg_component.html.erb +30 -0
  35. data/app/components/moduler/form/wysiwyg_component.rb +62 -0
  36. data/app/components/moduler/list_component.html.erb +15 -0
  37. data/app/components/moduler/list_component.rb +18 -0
  38. data/app/components/moduler/list_item_component.html.erb +23 -0
  39. data/app/components/moduler/list_item_component.rb +14 -0
  40. data/app/components/moduler/page_header_component.html.erb +16 -0
  41. data/app/components/moduler/page_header_component.rb +12 -0
  42. data/app/components/moduler/sidebar_action_component.html.erb +5 -0
  43. data/app/components/moduler/sidebar_action_component.rb +15 -0
  44. data/app/controllers/concerns/moduler/authentication.rb +10 -2
  45. data/app/controllers/moduler/administration/application_controller.rb +7 -0
  46. data/app/controllers/moduler/administration/articles_controller.rb +57 -0
  47. data/app/controllers/moduler/administration/home_controller.rb +9 -0
  48. data/app/controllers/moduler/administration/sessions_controller.rb +34 -0
  49. data/app/controllers/moduler/administration/users_controller.rb +56 -0
  50. data/app/controllers/moduler/application_controller.rb +15 -0
  51. data/app/controllers/moduler/articles_controller.rb +15 -0
  52. data/app/controllers/moduler/profile/sessions/revocation_controller.rb +14 -0
  53. data/app/controllers/moduler/profile/sessions_controller.rb +21 -0
  54. data/app/controllers/moduler/profile_controller.rb +27 -0
  55. data/app/controllers/moduler/sessions_controller.rb +8 -1
  56. data/app/controllers/moduler/users_controller.rb +7 -0
  57. data/app/filters/application_filter.rb +41 -0
  58. data/app/filters/query_filter.rb +18 -0
  59. data/app/lib/moduler/form_builder.rb +2 -2
  60. data/app/models/moduler/article.rb +25 -0
  61. data/app/models/moduler/session.rb +12 -0
  62. data/app/models/moduler/user.rb +9 -0
  63. data/app/policies/moduler/administration/article_policy.rb +29 -0
  64. data/app/policies/moduler/administration/base_policy.rb +11 -0
  65. data/app/policies/moduler/administration/home_policy.rb +9 -0
  66. data/app/policies/moduler/administration/session_policy.rb +13 -0
  67. data/app/policies/moduler/administration/user_policy.rb +25 -0
  68. data/app/policies/moduler/application_policy.rb +38 -0
  69. data/app/views/kaminari/_gap.html.erb +1 -0
  70. data/app/views/kaminari/_next_page.html.erb +1 -0
  71. data/app/views/kaminari/_page.html.erb +3 -0
  72. data/app/views/kaminari/_paginator.html.erb +13 -0
  73. data/app/views/kaminari/_prev_page.html.erb +1 -0
  74. data/app/views/layouts/moduler/application.html.erb +5 -6
  75. data/app/views/layouts/moduler/articles.html.erb +18 -0
  76. data/app/views/moduler/administration/articles/_article.html.erb +13 -0
  77. data/app/views/moduler/administration/articles/_form.html.erb +18 -0
  78. data/app/views/moduler/administration/articles/edit.html.erb +1 -0
  79. data/app/views/moduler/administration/articles/index.html.erb +13 -0
  80. data/app/views/moduler/administration/articles/new.html.erb +1 -0
  81. data/app/views/moduler/administration/home/index.html.erb +6 -0
  82. data/app/views/moduler/administration/sessions/_session.html.erb +30 -0
  83. data/app/views/moduler/administration/sessions/index.html.erb +9 -0
  84. data/app/views/moduler/administration/users/_user.html.erb +13 -0
  85. data/app/views/moduler/administration/users/edit.html.erb +14 -0
  86. data/app/views/moduler/administration/users/index.html.erb +13 -0
  87. data/app/views/moduler/administration/users/new.html.erb +18 -0
  88. data/app/views/moduler/articles/_article.html.erb +16 -0
  89. data/app/views/moduler/articles/index.html.erb +15 -0
  90. data/app/views/moduler/articles/show.html.erb +21 -0
  91. data/app/views/moduler/common/_flash.html.erb +16 -0
  92. data/app/views/moduler/common/_header.html.erb +12 -3
  93. data/app/views/moduler/common/_sidebar.html.erb +7 -0
  94. data/app/views/moduler/common/administration/_sidebar.html.erb +12 -0
  95. data/app/views/moduler/common/profile/_sidebar.html.erb +6 -0
  96. data/app/views/moduler/home/index.html.erb +6 -0
  97. data/app/views/moduler/profile/edit.html.erb +28 -0
  98. data/app/views/moduler/profile/sessions/_session.html.erb +52 -0
  99. data/app/views/moduler/profile/sessions/index.html.erb +26 -0
  100. data/app/views/moduler/sessions/new.html.erb +4 -18
  101. data/app/views/moduler/users/new.html.erb +5 -32
  102. data/config/importmap.rb +3 -0
  103. data/config/locales/moduler/administration/articles/en.yml +37 -0
  104. data/config/locales/moduler/administration/sessions/en.yml +24 -0
  105. data/config/locales/moduler/administration/users/en.yml +43 -0
  106. data/config/locales/moduler/articles/en.yml +13 -0
  107. data/config/locales/moduler/common/en.yml +12 -0
  108. data/config/locales/moduler/components/en.yml +27 -0
  109. data/config/locales/moduler/en.yml +4 -0
  110. data/config/locales/moduler/profile/en.yml +26 -0
  111. data/config/locales/moduler/profile/sessions/en.yml +25 -0
  112. data/config/locales/moduler/sessions/en.yml +1 -0
  113. data/config/locales/moduler/users/en.yml +7 -1
  114. data/config/routes.rb +18 -1
  115. data/db/migrate/20261015171504_add_role_to_moduler_users.rb +5 -0
  116. data/db/migrate/20261020191709_enable_pg_trgm.rb +5 -0
  117. data/db/migrate/20261026203827_create_moduler_articles.rb +15 -0
  118. data/lib/moduler/engine.rb +2 -0
  119. data/lib/moduler/version.rb +1 -1
  120. data/lib/moduler.rb +7 -0
  121. data/lib/tasks/moduler_tasks.rake +2 -4
  122. metadata +202 -5
  123. data/app/assets/javascripts/moduler/controllers/hello_controller.js +0 -6
  124. data/app/controllers/moduler/hello_controller.rb +0 -5
  125. data/app/views/moduler/hello/index.html.erb +0 -5
@@ -0,0 +1,14 @@
1
+ <div class="mt-2">
2
+ <% if @options.key?(:label) %>
3
+ <%= @form.label @name, @options[:label], class: "block text-sm text-slate-500" %>
4
+ <% end %>
5
+ <div class="mt-2">
6
+ <%= @form.text_area @name, class: "mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-900 focus:border-slate-900 focus:outline-none focus:ring-1 focus:ring-slate-900" %>
7
+ </div>
8
+ <% if @options.key?(:description) %>
9
+ <p class="mt-1 text-xs text-gray-400"><%= @options[:description] %></p>
10
+ <% end %>
11
+ <% if @form.error?(@name) %>
12
+ <%= @form.error(@name, class: "text-red-500 text-sm mt-1") %>
13
+ <% end %>
14
+ </div>
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moduler
4
+ class Form::TextareaComponent < ViewComponent::Base
5
+ def initialize(form, name, options = {})
6
+ @form = form
7
+ @name = name
8
+ @options = options
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,30 @@
1
+ <div class="mt-2">
2
+ <% if @options.key?(:label) %>
3
+ <%= @form.label @name, @options[:label], class: "block text-sm text-slate-500" %>
4
+ <% end %>
5
+ <%= tag.div class: "mt-2", data: controller_data do %>
6
+ <div class="flex flex-wrap gap-1 rounded-t-md border border-b-0 border-slate-300 bg-slate-50 px-2 py-1.5">
7
+ <% ACTIONS.each do |action, style| %>
8
+ <button type="button" data-action="click->wysiwyg#<%= action %>" class="rounded px-2 py-1 text-sm text-slate-700 hover:bg-slate-200 <%= style %>"><%= t("moduler.components.form.wysiwyg.action.#{action.to_s.underscore}") %></button>
9
+ <% end %>
10
+ <% if upload? %>
11
+ <button type="button" data-action="click->wysiwyg#upload" class="rounded px-2 py-1 text-sm text-slate-700 hover:bg-slate-200"><%= t("moduler.components.form.wysiwyg.action.upload") %></button>
12
+ <% end %>
13
+ </div>
14
+ <%= @form.text_area @name, rows: rows,
15
+ data: { wysiwyg_target: "markup", action: "input->wysiwyg#render keydown->wysiwyg#insertTab" },
16
+ class: "block w-full border border-slate-300 px-3 py-2 font-mono text-sm text-slate-900 focus:border-slate-900 focus:outline-none focus:ring-1 focus:ring-slate-900" %>
17
+ <div class="prose prose-sm prose-slate prose-100 rounded-b-md border border-t-0 border-slate-300 bg-slate-50 px-4 py-3 prose-img:w-full prose-img:max-w-5xl" data-wysiwyg-target="viewer"></div>
18
+ <% if upload? %>
19
+ <% attached_signed_ids.each do |signed_id| %>
20
+ <%= hidden_field_tag attachments_field_name, signed_id, id: nil %>
21
+ <% end %>
22
+ <% end %>
23
+ <% end %>
24
+ <% if @options.key?(:description) %>
25
+ <p class="mt-1 text-xs text-gray-400"><%= @options[:description] %></p>
26
+ <% end %>
27
+ <% if @form.error?(@name) %>
28
+ <%= @form.error(@name, class: "text-red-500 text-sm mt-1") %>
29
+ <% end %>
30
+ </div>
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moduler
4
+ class Form::WysiwygComponent < ViewComponent::Base
5
+ ACTIONS = {
6
+ bold: "font-semibold",
7
+ italic: "italic",
8
+ h1: "font-semibold",
9
+ h2: "font-semibold",
10
+ h3: "font-semibold",
11
+ quote: "",
12
+ code: "font-mono",
13
+ ul: "",
14
+ ol: "",
15
+ fencedCode: "font-mono",
16
+ link: "",
17
+ image: ""
18
+ }.freeze
19
+
20
+ def initialize(form, name, options = {})
21
+ @form = form
22
+ @name = name
23
+ @options = options
24
+ end
25
+
26
+ def rows
27
+ @options.fetch(:rows, 14)
28
+ end
29
+
30
+ # Name of the has_many_attached association that receives uploaded images,
31
+ # `attachments: false` disables uploading.
32
+ def attachments
33
+ @options.fetch(:attachments, :images)
34
+ end
35
+
36
+ def upload?
37
+ attachments.present? && @form.object.respond_to?(attachments)
38
+ end
39
+
40
+ def attachments_field_name
41
+ "#{@form.object_name}[#{attachments}][]"
42
+ end
43
+
44
+ # Assigning has_many_attached replaces existing attachments, so already attached
45
+ # blobs are submitted again together with the newly uploaded ones. Blobs rejected
46
+ # by validation are not persisted, so they have no signed id and are skipped.
47
+ def attached_signed_ids
48
+ @form.object.public_send(attachments).map(&:blob).select(&:persisted?).map(&:signed_id)
49
+ end
50
+
51
+ def controller_data
52
+ data = { controller: "wysiwyg" }
53
+ return data unless upload?
54
+
55
+ data.merge(
56
+ wysiwyg_direct_upload_url_value: helpers.main_app.rails_direct_uploads_path,
57
+ wysiwyg_blob_url_value: helpers.main_app.rails_service_blob_path(":signed_id", ":filename"),
58
+ wysiwyg_attachments_field_name_value: attachments_field_name
59
+ )
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,15 @@
1
+ <div class="mt-6 flex-1">
2
+ <% if @collection.any? %>
3
+ <ul id="<%= @options[:id] %>" role="list" class="divide-y divide-slate-100 rounded-lg border border-slate-200 bg-white">
4
+ <%= content %>
5
+ </ul>
6
+ <% else %>
7
+ <p class="rounded-lg border border-dashed border-slate-300 px-4 py-8 text-center text-sm text-slate-500"><%= empty_message %></p>
8
+ <% end %>
9
+ </div>
10
+
11
+ <% if paginated? %>
12
+ <div class="mt-4 flex justify-end">
13
+ <%= helpers.paginate @collection %>
14
+ </div>
15
+ <% end %>
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moduler
4
+ class ListComponent < ViewComponent::Base
5
+ def initialize(collection, options = {})
6
+ @collection = collection
7
+ @options = options
8
+ end
9
+
10
+ def empty_message
11
+ @options.fetch(:empty) { t("moduler.components.list.empty") }
12
+ end
13
+
14
+ def paginated?
15
+ @collection.respond_to?(:total_pages)
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,23 @@
1
+ <li id="<%= @options[:id] %>" class="flex items-center justify-between gap-4 px-4 py-4 sm:px-5">
2
+ <div class="min-w-0">
3
+ <div class="flex flex-wrap items-center gap-2">
4
+ <p class="truncate text-sm font-semibold text-gray-900"><%= @title %></p>
5
+
6
+ <% badges.each do |badge| %>
7
+ <%= badge %>
8
+ <% end %>
9
+ </div>
10
+
11
+ <% if meta? %>
12
+ <p class="mt-1 text-xs text-slate-400"><%= meta %></p>
13
+ <% end %>
14
+ </div>
15
+
16
+ <% if actions? %>
17
+ <div class="flex shrink-0 items-center gap-2">
18
+ <% actions.each do |action| %>
19
+ <%= action %>
20
+ <% end %>
21
+ </div>
22
+ <% end %>
23
+ </li>
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moduler
4
+ class ListItemComponent < ViewComponent::Base
5
+ renders_many :badges, Moduler::BadgeComponent
6
+ renders_one :meta
7
+ renders_many :actions
8
+
9
+ def initialize(title, options = {})
10
+ @title = title
11
+ @options = options
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,16 @@
1
+ <div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
2
+ <div>
3
+ <h1 class="text-base font-semibold text-gray-900"><%= @title %></h1>
4
+ <% if @options.key?(:description) %>
5
+ <p class="mt-1 text-sm text-slate-500"><%= @options[:description] %></p>
6
+ <% end %>
7
+ </div>
8
+
9
+ <% if actions? %>
10
+ <div class="flex shrink-0 items-center gap-2">
11
+ <% actions.each do |action| %>
12
+ <%= action %>
13
+ <% end %>
14
+ </div>
15
+ <% end %>
16
+ </div>
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moduler
4
+ class PageHeaderComponent < ViewComponent::Base
5
+ renders_many :actions
6
+
7
+ def initialize(title, options = {})
8
+ @title = title
9
+ @options = options
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,5 @@
1
+ <% if active? %>
2
+ <%= link_to @action, @route, class: "rounded-md px-3 py-2 bg-slate-100 font-medium text-gray-900" %>
3
+ <% else %>
4
+ <%= link_to @action, @route, class: "rounded-md px-3 py-2 text-slate-500 hover:bg-slate-50 hover:text-gray-900" %>
5
+ <% end %>
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moduler
4
+ class SidebarActionComponent < ViewComponent::Base
5
+ def initialize(action, route, expected_controller)
6
+ @action = action
7
+ @route = route
8
+ @expected_controller = expected_controller
9
+ end
10
+
11
+ def active?
12
+ controller_path.eql?(@expected_controller)
13
+ end
14
+ end
15
+ end
@@ -28,7 +28,9 @@ module Moduler
28
28
  end
29
29
 
30
30
  def find_session_by_cookie
31
- Session.find_by(id: cookies.signed[:session_id], signed_out_at: nil) if cookies.signed[:session_id]
31
+ if cookies.signed[:session_id]
32
+ Session.active.find_by(id: cookies.signed[:session_id], signed_in_at: Session::LIFETIME.ago..)
33
+ end
32
34
  end
33
35
 
34
36
  def request_authentication
@@ -42,7 +44,13 @@ module Moduler
42
44
  def start_new_session_for(user)
43
45
  user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip, signed_in_at: Time.current).tap do |session|
44
46
  Current.session = session
45
- cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax }
47
+ cookies.signed[:session_id] = {
48
+ value: session.id,
49
+ expires: Session::LIFETIME.from_now,
50
+ httponly: true,
51
+ same_site: :lax,
52
+ secure: Rails.env.production?
53
+ }
46
54
  end
47
55
  end
48
56
 
@@ -0,0 +1,7 @@
1
+ module Moduler
2
+ module Administration
3
+ class ApplicationController < ::Moduler::ApplicationController
4
+ after_action :verify_authorized
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,57 @@
1
+ module Moduler
2
+ module Administration
3
+ class ArticlesController < ApplicationController
4
+ before_action -> { authorize([ :moduler, :administration, :article ]) }
5
+ before_action :set_article, only: %i[ edit update destroy ]
6
+
7
+ def index
8
+ @filter, @articles = QueryFilter.new(Article, permitted_filter_parameters).call
9
+ end
10
+
11
+ def new
12
+ @article = Article.new
13
+ end
14
+
15
+ def create
16
+ @article = Article.new(permitted_parameters)
17
+
18
+ if @article.save
19
+ redirect_to moduler.administration_articles_path, notice: t(".notice")
20
+ else
21
+ render :new, status: :unprocessable_entity
22
+ end
23
+ end
24
+
25
+ def edit; end
26
+
27
+ def update
28
+ if @article.update(permitted_parameters)
29
+ redirect_to moduler.administration_articles_path, notice: t(".notice")
30
+ else
31
+ render :edit, status: :unprocessable_entity
32
+ end
33
+ end
34
+
35
+ def destroy
36
+ @article.destroy!
37
+
38
+ redirect_to moduler.administration_articles_path, status: :see_other, notice: t(".notice")
39
+ end
40
+
41
+ private
42
+
43
+ def set_article
44
+ @article = Article.find(params[:id])
45
+ authorize(@article, policy_class: ArticlePolicy)
46
+ end
47
+
48
+ def permitted_parameters
49
+ params.expect(article: [ :path, :title, :description, :keywords, :content, images: [] ])
50
+ end
51
+
52
+ def permitted_filter_parameters
53
+ (params.permit(filter: %i[ query ])[:filter] || {}).merge(params.permit(:page))
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,9 @@
1
+ module Moduler
2
+ module Administration
3
+ class HomeController < ApplicationController
4
+ before_action -> { authorize([ :moduler, :administration, :home ]) }
5
+
6
+ def index; end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,34 @@
1
+ module Moduler
2
+ module Administration
3
+ class SessionsController < ApplicationController
4
+ before_action -> { authorize([ :moduler, :administration, :session ]) }
5
+ before_action :set_session, only: %i[ destroy ]
6
+
7
+ def index
8
+ @filter, @sessions = QueryFilter.new(Session.includes(:user), permitted_filter_parameters).call
9
+ end
10
+
11
+ def destroy
12
+ @session.touch(:signed_out_at)
13
+
14
+ if @session == Current.session
15
+ cookies.delete(:session_id)
16
+ redirect_to moduler.new_session_path, status: :see_other
17
+ else
18
+ redirect_to moduler.administration_sessions_path, status: :see_other, notice: t(".notice")
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def set_session
25
+ @session = Session.active.find(params[:id])
26
+ authorize(@session, policy_class: SessionPolicy)
27
+ end
28
+
29
+ def permitted_filter_parameters
30
+ (params.permit(filter: %i[ query ])[:filter] || {}).merge(params.permit(:page))
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,56 @@
1
+ module Moduler
2
+ module Administration
3
+ class UsersController < ApplicationController
4
+ before_action -> { authorize([ :moduler, :administration, :user ]) }
5
+ before_action :set_user, only: %i[ edit update ]
6
+
7
+ def index
8
+ @filter, @users = QueryFilter.new(User, permitted_filter_parameters).call
9
+ end
10
+
11
+ def new
12
+ @user = User.new
13
+ end
14
+
15
+ def create
16
+ @user = User.new(permitted_parameters)
17
+
18
+ if @user.save
19
+ redirect_to moduler.administration_users_path, notice: t(".notice")
20
+ else
21
+ render :new, status: :unprocessable_entity
22
+ end
23
+ end
24
+
25
+ def edit
26
+ end
27
+
28
+ def update
29
+ if @user.update(permitted_update_parameters)
30
+ redirect_to moduler.administration_users_path, notice: t(".notice")
31
+ else
32
+ render :edit, status: :unprocessable_entity
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def set_user
39
+ @user = User.find(params[:id])
40
+ authorize(@user, policy_class: UserPolicy)
41
+ end
42
+
43
+ def permitted_parameters
44
+ params.expect(user: %i[ email_address role password ])
45
+ end
46
+
47
+ def permitted_update_parameters
48
+ params.expect(user: %i[ email_address role ])
49
+ end
50
+
51
+ def permitted_filter_parameters
52
+ (params.permit(filter: %i[ query ])[:filter] || {}).merge(params.permit(:page))
53
+ end
54
+ end
55
+ end
56
+ end
@@ -1,7 +1,22 @@
1
1
  module Moduler
2
2
  class ApplicationController < ActionController::Base
3
3
  include Authentication
4
+ include Pundit::Authorization
4
5
 
5
6
  default_form_builder FormBuilder
7
+
8
+ rescue_from Pundit::NotAuthorizedError, with: :not_authorized
9
+
10
+ helper_method :current_user
11
+
12
+ private
13
+
14
+ def current_user
15
+ Current.user
16
+ end
17
+
18
+ def not_authorized
19
+ redirect_to moduler.root_path, alert: t("moduler.flash.not_authorized")
20
+ end
6
21
  end
7
22
  end
@@ -0,0 +1,15 @@
1
+ module Moduler
2
+ class ArticlesController < ApplicationController
3
+ layout "moduler/articles"
4
+
5
+ allow_unauthenticated_access
6
+
7
+ def index
8
+ @articles = Article.order(created_at: :desc).page(params[:page])
9
+ end
10
+
11
+ def show
12
+ @article = Article.find_by!(path: params[:path])
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,14 @@
1
+ module Moduler
2
+ module Profile
3
+ module Sessions
4
+ class RevocationController < ApplicationController
5
+ def destroy
6
+ Current.user.sessions.active.update_all(signed_out_at: Time.current)
7
+ cookies.delete(:session_id)
8
+
9
+ redirect_to moduler.new_session_path, status: :see_other
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,21 @@
1
+ module Moduler
2
+ module Profile
3
+ class SessionsController < ApplicationController
4
+ def index
5
+ @sessions = Current.user.sessions.order(created_at: :desc).page(params[:page]).per(10)
6
+ end
7
+
8
+ def destroy
9
+ session = Current.user.sessions.active.find(params[:id])
10
+ session.touch(:signed_out_at)
11
+
12
+ if session == Current.session
13
+ cookies.delete(:session_id)
14
+ redirect_to moduler.new_session_path, status: :see_other
15
+ else
16
+ redirect_to moduler.profile_sessions_path, status: :see_other
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,27 @@
1
+ module Moduler
2
+ class ProfileController < ApplicationController
3
+ def edit
4
+ @user = Current.user
5
+ end
6
+
7
+ def update
8
+ @user = Current.user
9
+
10
+ @user.assign_attributes(permitted_parameters)
11
+
12
+ if @user.save(context: :self_service_update)
13
+ @user.sessions.active.where.not(id: Current.session.id).update_all(signed_out_at: Time.current) if @user.saved_change_to_password_digest?
14
+
15
+ redirect_to moduler.edit_profile_path, notice: t(".notice")
16
+ else
17
+ render :edit, status: :unprocessable_entity
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def permitted_parameters
24
+ params.expect(user: %i[ password_challenge password password_confirmation ])
25
+ end
26
+ end
27
+ end
@@ -2,6 +2,13 @@ module Moduler
2
2
  class SessionsController < ApplicationController
3
3
  allow_unauthenticated_access only: %i[ new create ]
4
4
 
5
+ rate_limit to: 10, within: 3.minutes, only: :create,
6
+ with: -> { redirect_to moduler.new_session_path, alert: t("moduler.sessions.create.rate_limited") }
7
+
8
+ rate_limit to: 5, within: 3.minutes, only: :create,
9
+ by: -> { params.dig(:user, :email_address) },
10
+ with: -> { redirect_to moduler.new_session_path, alert: t("moduler.sessions.create.rate_limited") }
11
+
5
12
  def new
6
13
  if authenticated?
7
14
  redirect_to after_authentication_url
@@ -26,7 +33,7 @@ module Moduler
26
33
 
27
34
  def destroy
28
35
  terminate_session
29
- redirect_to moduler.new_session_path
36
+ redirect_to moduler.new_session_path, status: :see_other
30
37
  end
31
38
  end
32
39
  end
@@ -2,6 +2,13 @@ module Moduler
2
2
  class UsersController < ApplicationController
3
3
  allow_unauthenticated_access only: %i[ new create ]
4
4
 
5
+ rate_limit to: 10, within: 3.minutes, only: :create,
6
+ with: -> { redirect_to moduler.new_user_path, alert: t("moduler.users.create.rate_limited") }
7
+
8
+ rate_limit to: 5, within: 3.minutes, only: :create,
9
+ by: -> { params.dig(:user, :email_address) },
10
+ with: -> { redirect_to moduler.new_user_path, alert: t("moduler.users.create.rate_limited") }
11
+
5
12
  def new
6
13
  if authenticated?
7
14
  redirect_to after_authentication_url
@@ -0,0 +1,41 @@
1
+ class ApplicationFilter
2
+ include ActiveModel::Model
3
+ include ActiveModel::Dirty
4
+ include ActiveModel::Attributes
5
+
6
+ class << self
7
+ def modifiers
8
+ @modifiers ||= []
9
+ end
10
+ end
11
+
12
+ def initialize(scope, parameters, options = {})
13
+ super(parameters)
14
+
15
+ @scope = scope
16
+ @options = options
17
+ end
18
+
19
+ def call
20
+ [ filter, results ]
21
+ end
22
+
23
+ private
24
+
25
+ def filter
26
+ return self unless changed?
27
+
28
+ tap(&:valid?)
29
+ end
30
+
31
+ def results
32
+ return @scope.none.page(1) if !changed? && @options.fetch(:allow_empty, false)
33
+ return @scope.none.page(1) if invalid?
34
+
35
+ self.class.modifiers.each do |modifier|
36
+ @scope = modifier.call(@scope, self) || @scope
37
+ end
38
+
39
+ @scope
40
+ end
41
+ end
@@ -0,0 +1,18 @@
1
+ class QueryFilter < ApplicationFilter
2
+ attribute :query, :string
3
+ attribute :page, :integer
4
+
5
+ validates :query, length: { maximum: 100 }
6
+
7
+ modifiers << proc do |scope, filter|
8
+ scope.page(filter.page).per(10)
9
+ end
10
+
11
+ modifiers << proc do |scope, _filter|
12
+ scope.order(created_at: :desc)
13
+ end
14
+
15
+ modifiers << proc do |scope, filter|
16
+ scope.search_by_query(filter.query) if filter.query.present?
17
+ end
18
+ end
@@ -5,7 +5,7 @@ module Moduler
5
5
 
6
6
  @template.content_tag :div, **options do
7
7
  @object.errors[field].first
8
- end.html_safe
8
+ end
9
9
  end
10
10
 
11
11
  def full_error(field, options = {})
@@ -13,7 +13,7 @@ module Moduler
13
13
 
14
14
  @template.content_tag :div, **options do
15
15
  @object.errors.full_messages_for(field).first
16
- end.html_safe
16
+ end
17
17
  end
18
18
 
19
19
  def error?(field)