moduler 0.4.0 → 0.5.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/moduler/tailwind.css +105 -0
  3. data/app/components/moduler/form/file_component.html.erb +14 -0
  4. data/app/components/moduler/form/file_component.rb +11 -0
  5. data/app/controllers/concerns/moduler/authentication.rb +1 -1
  6. data/app/controllers/concerns/moduler/require_document_acceptance.rb +31 -0
  7. data/app/controllers/moduler/administration/application_controller.rb +2 -0
  8. data/app/controllers/moduler/administration/documents_controller.rb +56 -0
  9. data/app/controllers/moduler/administration/users_controller.rb +2 -2
  10. data/app/controllers/moduler/application_controller.rb +1 -0
  11. data/app/controllers/moduler/articles_controller.rb +1 -0
  12. data/app/controllers/moduler/document_acceptances_controller.rb +25 -0
  13. data/app/controllers/moduler/profile/sessions/revocation_controller.rb +2 -0
  14. data/app/controllers/moduler/profile/sessions_controller.rb +2 -0
  15. data/app/controllers/moduler/profile_controller.rb +2 -0
  16. data/app/controllers/moduler/sessions_controller.rb +15 -6
  17. data/app/controllers/moduler/users_controller.rb +1 -0
  18. data/app/models/moduler/document.rb +17 -0
  19. data/app/models/moduler/user.rb +8 -0
  20. data/app/models/moduler/user_document.rb +8 -0
  21. data/app/policies/moduler/administration/document_policy.rb +25 -0
  22. data/app/services/moduler/document_acceptance.rb +43 -0
  23. data/app/views/moduler/administration/documents/_document.html.erb +19 -0
  24. data/app/views/moduler/administration/documents/_form.html.erb +23 -0
  25. data/app/views/moduler/administration/documents/edit.html.erb +1 -0
  26. data/app/views/moduler/administration/documents/index.html.erb +13 -0
  27. data/app/views/moduler/administration/documents/new.html.erb +1 -0
  28. data/app/views/moduler/administration/users/edit.html.erb +1 -0
  29. data/app/views/moduler/administration/users/new.html.erb +1 -0
  30. data/app/views/moduler/common/administration/_sidebar.html.erb +1 -0
  31. data/app/views/moduler/document_acceptances/new.html.erb +36 -0
  32. data/config/locales/moduler/administration/documents/en.yml +35 -0
  33. data/config/locales/moduler/administration/users/en.yml +6 -0
  34. data/config/locales/moduler/common/en.yml +1 -0
  35. data/config/locales/moduler/document_acceptances/en.yml +17 -0
  36. data/config/locales/moduler/sessions/en.yml +1 -0
  37. data/config/routes.rb +3 -0
  38. data/db/migrate/20261104120000_add_status_to_moduler_users.rb +6 -0
  39. data/db/migrate/20261104205837_create_moduler_documents.rb +10 -0
  40. data/db/migrate/20261104205950_create_moduler_user_documents.rb +12 -0
  41. data/lib/moduler/version.rb +1 -1
  42. metadata +22 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bef4750b890390a7ed190a171abecbad432378673ed5aee71487cdf669369b11
4
- data.tar.gz: 46782a61ddd7ac48ddd10da394315110d1673d4c0b12fdae6be3136013bbfca8
3
+ metadata.gz: 3ec81c1092462c6513cd56e7953a2101eae358249f501d32993f1666bfd8867a
4
+ data.tar.gz: c92cde6155a2bc5fe17193155426ac2e593d6979092dae58e2ec64f0940bb9e9
5
5
  SHA512:
6
- metadata.gz: 71aaf10f6880c35954bd3c8d60ba22a2bd0b251565a3ffdada709e566ab164fc771d3ec7ac4fe5af57976ee34748c8b413c25b814ac5c96ba5a697f1f135dc03
7
- data.tar.gz: f4b4b157a4bbb5490c64ec907d6e5a4f68ff7900421238761d0e80b1744c0d855eba512c301c314f6218f373be82ab2dae0286c9d2680600d64bd42e06accb60
6
+ metadata.gz: fd6b3fb551373a67a76faeb79ffb8d49fa50740e2c9c3fb11838d9b99199e0bfa3f77f42e8dc847a86fb3b32f37371caef0dea2734b0d1c5ec75032abae574eb
7
+ data.tar.gz: 854b1562ccdaf04ccaa17c7d96418100374b7cb7294f06e3120babb7098e94c7605a18a531e31166a310df47abbd1ed311202ec0d3b7213fcc8cd70522337ff6
@@ -32,6 +32,7 @@
32
32
  --color-slate-600: oklch(44.6% 0.043 257.281);
33
33
  --color-slate-700: oklch(37.2% 0.044 257.287);
34
34
  --color-slate-900: oklch(20.8% 0.042 265.755);
35
+ --color-gray-50: oklch(98.5% 0.002 247.839);
35
36
  --color-gray-200: oklch(92.8% 0.006 264.531);
36
37
  --color-gray-300: oklch(87.2% 0.01 258.338);
37
38
  --color-gray-400: oklch(70.7% 0.022 261.325);
@@ -41,6 +42,7 @@
41
42
  --spacing: 0.25rem;
42
43
  --container-sm: 24rem;
43
44
  --container-lg: 32rem;
45
+ --container-2xl: 42rem;
44
46
  --container-3xl: 48rem;
45
47
  --container-5xl: 64rem;
46
48
  --text-xs: 0.75rem;
@@ -872,6 +874,9 @@
872
874
  margin-bottom: 0;
873
875
  }
874
876
  }
877
+ .mt-0\.5 {
878
+ margin-top: calc(var(--spacing) * 0.5);
879
+ }
875
880
  .mt-1 {
876
881
  margin-top: calc(var(--spacing) * 1);
877
882
  }
@@ -955,6 +960,9 @@
955
960
  .w-full {
956
961
  width: 100%;
957
962
  }
963
+ .max-w-2xl {
964
+ max-width: var(--container-2xl);
965
+ }
958
966
  .max-w-3xl {
959
967
  max-width: var(--container-3xl);
960
968
  }
@@ -1052,6 +1060,11 @@
1052
1060
  border-bottom-width: calc(1px * calc(1 - var(--tw-divide-y-reverse)));
1053
1061
  }
1054
1062
  }
1063
+ .divide-gray-200 {
1064
+ :where(& > :not(:last-child)) {
1065
+ border-color: var(--color-gray-200);
1066
+ }
1067
+ }
1055
1068
  .divide-slate-100 {
1056
1069
  :where(& > :not(:last-child)) {
1057
1070
  border-color: var(--color-slate-100);
@@ -1118,6 +1131,9 @@
1118
1131
  .border-gray-200 {
1119
1132
  border-color: var(--color-gray-200);
1120
1133
  }
1134
+ .border-gray-300 {
1135
+ border-color: var(--color-gray-300);
1136
+ }
1121
1137
  .border-red-200 {
1122
1138
  border-color: var(--color-red-200);
1123
1139
  }
@@ -1333,6 +1349,9 @@
1333
1349
  .italic {
1334
1350
  font-style: italic;
1335
1351
  }
1352
+ .accent-custom {
1353
+ accent-color: var(--color-custom);
1354
+ }
1336
1355
  .opacity-0 {
1337
1356
  opacity: 0%;
1338
1357
  }
@@ -1440,6 +1459,76 @@
1440
1459
  }
1441
1460
  }
1442
1461
  }
1462
+ .file\:mr-3 {
1463
+ &::file-selector-button {
1464
+ margin-right: calc(var(--spacing) * 3);
1465
+ }
1466
+ }
1467
+ .file\:rounded-md {
1468
+ &::file-selector-button {
1469
+ border-radius: var(--radius-md);
1470
+ }
1471
+ }
1472
+ .file\:border-0 {
1473
+ &::file-selector-button {
1474
+ border-style: var(--tw-border-style);
1475
+ border-width: 0px;
1476
+ }
1477
+ }
1478
+ .file\:bg-white {
1479
+ &::file-selector-button {
1480
+ background-color: var(--color-white);
1481
+ }
1482
+ }
1483
+ .file\:px-3 {
1484
+ &::file-selector-button {
1485
+ padding-inline: calc(var(--spacing) * 3);
1486
+ }
1487
+ }
1488
+ .file\:py-1\.5 {
1489
+ &::file-selector-button {
1490
+ padding-block: calc(var(--spacing) * 1.5);
1491
+ }
1492
+ }
1493
+ .file\:text-sm {
1494
+ &::file-selector-button {
1495
+ font-size: var(--text-sm);
1496
+ line-height: var(--tw-leading, var(--text-sm--line-height));
1497
+ }
1498
+ }
1499
+ .file\:font-semibold {
1500
+ &::file-selector-button {
1501
+ --tw-font-weight: var(--font-weight-semibold);
1502
+ font-weight: var(--font-weight-semibold);
1503
+ }
1504
+ }
1505
+ .file\:text-gray-900 {
1506
+ &::file-selector-button {
1507
+ color: var(--color-gray-900);
1508
+ }
1509
+ }
1510
+ .file\:shadow-xs {
1511
+ &::file-selector-button {
1512
+ --tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05));
1513
+ box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
1514
+ }
1515
+ }
1516
+ .file\:ring-1 {
1517
+ &::file-selector-button {
1518
+ --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
1519
+ box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
1520
+ }
1521
+ }
1522
+ .file\:ring-gray-300 {
1523
+ &::file-selector-button {
1524
+ --tw-ring-color: var(--color-gray-300);
1525
+ }
1526
+ }
1527
+ .file\:ring-inset {
1528
+ &::file-selector-button {
1529
+ --tw-ring-inset: inset;
1530
+ }
1531
+ }
1443
1532
  .placeholder\:text-gray-400 {
1444
1533
  &::placeholder {
1445
1534
  color: var(--color-gray-400);
@@ -1520,6 +1609,13 @@
1520
1609
  }
1521
1610
  }
1522
1611
  }
1612
+ .hover\:underline {
1613
+ &:hover {
1614
+ @media (hover: hover) {
1615
+ text-decoration-line: underline;
1616
+ }
1617
+ }
1618
+ }
1523
1619
  .hover\:ring-red-200 {
1524
1620
  &:hover {
1525
1621
  @media (hover: hover) {
@@ -1527,6 +1623,15 @@
1527
1623
  }
1528
1624
  }
1529
1625
  }
1626
+ .hover\:file\:bg-gray-50 {
1627
+ &:hover {
1628
+ @media (hover: hover) {
1629
+ &::file-selector-button {
1630
+ background-color: var(--color-gray-50);
1631
+ }
1632
+ }
1633
+ }
1634
+ }
1530
1635
  .focus\:border-slate-900 {
1531
1636
  &:focus {
1532
1637
  border-color: var(--color-slate-900);
@@ -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.file_field @name, accept: @options[:accept], class: "block w-full text-sm text-gray-900 file:mr-3 file:rounded-md file:border-0 file:bg-white file:px-3 file:py-1.5 file:text-sm file:font-semibold file:text-gray-900 file:shadow-xs file:ring-1 file:ring-inset file:ring-gray-300 hover:file:bg-gray-50" %>
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::FileComponent < ViewComponent::Base
5
+ def initialize(form, name, options = {})
6
+ @form = form
7
+ @name = name
8
+ @options = options
9
+ end
10
+ end
11
+ end
@@ -29,7 +29,7 @@ module Moduler
29
29
 
30
30
  def find_session_by_cookie
31
31
  if cookies.signed[:session_id]
32
- Session.active.find_by(id: cookies.signed[:session_id], signed_in_at: Session::LIFETIME.ago..)
32
+ Session.active.joins(:user).where(user: { status: :active }).find_by(id: cookies.signed[:session_id], signed_in_at: Session::LIFETIME.ago..)
33
33
  end
34
34
  end
35
35
 
@@ -0,0 +1,31 @@
1
+ module Moduler
2
+ # Blocks the panel for signed-in users until they accept every document.
3
+ # Controllers that must stay available meanwhile (the profile, signing out,
4
+ # public pages) opt out with +skip_document_acceptance+.
5
+ module RequireDocumentAcceptance
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ before_action :require_document_acceptance
10
+ helper_method :document_acceptance_required?
11
+ end
12
+
13
+ class_methods do
14
+ def skip_document_acceptance(**options)
15
+ skip_before_action :require_document_acceptance, **options
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def document_acceptance_required?
22
+ return @document_acceptance_required if defined?(@document_acceptance_required)
23
+
24
+ @document_acceptance_required = Current.user.present? && Current.user.pending_documents.exists?
25
+ end
26
+
27
+ def require_document_acceptance
28
+ redirect_to moduler.new_document_acceptance_path if document_acceptance_required?
29
+ end
30
+ end
31
+ end
@@ -2,6 +2,8 @@ module Moduler
2
2
  module Administration
3
3
  class ApplicationController < ::Moduler::ApplicationController
4
4
  after_action :verify_authorized
5
+
6
+ skip_document_acceptance if: -> { current_user&.administrator? }
5
7
  end
6
8
  end
7
9
  end
@@ -0,0 +1,56 @@
1
+ module Moduler
2
+ module Administration
3
+ class DocumentsController < ApplicationController
4
+ before_action -> { authorize([ :moduler, :administration, :document ]) }
5
+ before_action :set_document, only: %i[ edit update ]
6
+
7
+ def index
8
+ @filter, @documents = QueryFilter.new(Document.with_attached_file, permitted_filter_parameters).call
9
+ end
10
+
11
+ def new
12
+ @document = Document.new
13
+ end
14
+
15
+ def create
16
+ @document = Document.new(permitted_parameters)
17
+
18
+ if @document.save
19
+ redirect_to moduler.administration_documents_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 @document.update(permitted_update_parameters)
29
+ redirect_to moduler.administration_documents_path, notice: t(".notice")
30
+ else
31
+ render :edit, status: :unprocessable_entity
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def set_document
38
+ @document = Document.find(params[:id])
39
+ authorize(@document, policy_class: DocumentPolicy)
40
+ end
41
+
42
+ def permitted_parameters
43
+ params.expect(document: %i[ name kind file ])
44
+ end
45
+
46
+ # The file is set only when the document is created and cannot be replaced.
47
+ def permitted_update_parameters
48
+ params.expect(document: %i[ name kind ])
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
@@ -41,11 +41,11 @@ module Moduler
41
41
  end
42
42
 
43
43
  def permitted_parameters
44
- params.expect(user: %i[ email_address role password ])
44
+ params.expect(user: %i[ email_address role status password ])
45
45
  end
46
46
 
47
47
  def permitted_update_parameters
48
- params.expect(user: %i[ email_address role ])
48
+ params.expect(user: %i[ email_address role status ])
49
49
  end
50
50
 
51
51
  def permitted_filter_parameters
@@ -1,6 +1,7 @@
1
1
  module Moduler
2
2
  class ApplicationController < ActionController::Base
3
3
  include Authentication
4
+ include RequireDocumentAcceptance
4
5
  include Pundit::Authorization
5
6
 
6
7
  default_form_builder FormBuilder
@@ -3,6 +3,7 @@ module Moduler
3
3
  layout "moduler/articles"
4
4
 
5
5
  allow_unauthenticated_access
6
+ skip_document_acceptance
6
7
 
7
8
  def index
8
9
  @articles = Article.order(created_at: :desc).page(params[:page])
@@ -0,0 +1,25 @@
1
+ module Moduler
2
+ class DocumentAcceptancesController < ApplicationController
3
+ skip_document_acceptance
4
+
5
+ before_action :set_document_acceptance
6
+
7
+ def new; end
8
+
9
+ def create
10
+ if @document_acceptance.call
11
+ redirect_to moduler.root_path, notice: t(".notice")
12
+ else
13
+ flash.now[:alert] = @document_acceptance.errors.full_messages.to_sentence
14
+
15
+ render :new, status: :unprocessable_entity
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def set_document_acceptance
22
+ @document_acceptance = DocumentAcceptance.new(Current.user, params.dig(:document_acceptance, :document_ids))
23
+ end
24
+ end
25
+ end
@@ -2,6 +2,8 @@ module Moduler
2
2
  module Profile
3
3
  module Sessions
4
4
  class RevocationController < ApplicationController
5
+ skip_document_acceptance
6
+
5
7
  def destroy
6
8
  Current.user.sessions.active.update_all(signed_out_at: Time.current)
7
9
  cookies.delete(:session_id)
@@ -1,6 +1,8 @@
1
1
  module Moduler
2
2
  module Profile
3
3
  class SessionsController < ApplicationController
4
+ skip_document_acceptance
5
+
4
6
  def index
5
7
  @sessions = Current.user.sessions.order(created_at: :desc).page(params[:page]).per(10)
6
8
  end
@@ -1,5 +1,7 @@
1
1
  module Moduler
2
2
  class ProfileController < ApplicationController
3
+ skip_document_acceptance
4
+
3
5
  def edit
4
6
  @user = Current.user
5
7
  end
@@ -1,6 +1,7 @@
1
1
  module Moduler
2
2
  class SessionsController < ApplicationController
3
3
  allow_unauthenticated_access only: %i[ new create ]
4
+ skip_document_acceptance
4
5
 
5
6
  rate_limit to: 10, within: 3.minutes, only: :create,
6
7
  with: -> { redirect_to moduler.new_session_path, alert: t("moduler.sessions.create.rate_limited") }
@@ -18,16 +19,14 @@ module Moduler
18
19
  end
19
20
 
20
21
  def create
21
- if user = User.authenticate_by(params.require(:user).permit(:email_address, :password))
22
+ user = User.authenticate_by(params.require(:user).permit(:email_address, :password))
23
+
24
+ if user&.active?
22
25
  start_new_session_for user
23
26
 
24
27
  redirect_to after_authentication_url
25
28
  else
26
- @user = User.new.tap do |user|
27
- user.errors.add(:base, t("moduler.sessions.create.invalid_credentials"))
28
- end
29
-
30
- render :new
29
+ render_sign_in_error(user ? "inactive_account" : "invalid_credentials")
31
30
  end
32
31
  end
33
32
 
@@ -35,5 +34,15 @@ module Moduler
35
34
  terminate_session
36
35
  redirect_to moduler.new_session_path, status: :see_other
37
36
  end
37
+
38
+ private
39
+
40
+ def render_sign_in_error(key)
41
+ @user = User.new.tap do |user|
42
+ user.errors.add(:base, t("moduler.sessions.create.#{key}"))
43
+ end
44
+
45
+ render :new
46
+ end
38
47
  end
39
48
  end
@@ -1,6 +1,7 @@
1
1
  module Moduler
2
2
  class UsersController < ApplicationController
3
3
  allow_unauthenticated_access only: %i[ new create ]
4
+ skip_document_acceptance
4
5
 
5
6
  rate_limit to: 10, within: 3.minutes, only: :create,
6
7
  with: -> { redirect_to moduler.new_user_path, alert: t("moduler.users.create.rate_limited") }
@@ -0,0 +1,17 @@
1
+ module Moduler
2
+ class Document < ApplicationRecord
3
+ include PgSearch::Model
4
+
5
+ KINDS = %w[ privacy_policy terms_and_conditions data_processing_agreement ].freeze
6
+
7
+ has_many :user_documents, class_name: "Moduler::UserDocument", foreign_key: "moduler_document_id", dependent: :delete_all
8
+
9
+ has_one_attached :file
10
+
11
+ validates :name, presence: true
12
+ validates :kind, presence: true, inclusion: { in: KINDS }
13
+ validates :file, presence: true, content_type: %w[ application/pdf ], size: { less_than: 10.megabytes }
14
+
15
+ pg_search_scope :search_by_query, against: %i[ name kind ], using: { trigram: { word_similarity: true } }
16
+ end
17
+ end
@@ -3,6 +3,8 @@ module Moduler
3
3
  include PgSearch::Model
4
4
 
5
5
  has_many :sessions, class_name: "Moduler::Session", foreign_key: "moduler_user_id", dependent: :destroy
6
+ has_many :user_documents, class_name: "Moduler::UserDocument", foreign_key: "moduler_user_id", dependent: :destroy
7
+ has_many :documents, class_name: "Moduler::Document", through: :user_documents
6
8
 
7
9
  has_secure_password
8
10
 
@@ -12,7 +14,13 @@ module Moduler
12
14
  validates :password, length: { minimum: 8 }, allow_nil: true
13
15
 
14
16
  enum :role, { member: 0, administrator: 1 }
17
+ enum :status, { waitlisted: 0, active: 1, inactive: 2 }, default: :active
15
18
 
16
19
  pg_search_scope :search_by_query, against: %i[ email_address ], using: { trigram: { word_similarity: true } }
20
+
21
+ # Documents the user has not accepted yet.
22
+ def pending_documents
23
+ Moduler::Document.where.not(id: user_documents.select(:moduler_document_id))
24
+ end
17
25
  end
18
26
  end
@@ -0,0 +1,8 @@
1
+ module Moduler
2
+ class UserDocument < ApplicationRecord
3
+ belongs_to :user, class_name: "Moduler::User", foreign_key: "moduler_user_id"
4
+ belongs_to :document, class_name: "Moduler::Document", foreign_key: "moduler_document_id"
5
+
6
+ validates :user, presence: true, uniqueness: { scope: :moduler_document_id }
7
+ end
8
+ end
@@ -0,0 +1,25 @@
1
+ module Moduler
2
+ module Administration
3
+ class DocumentPolicy < BasePolicy
4
+ def index?
5
+ administrator?
6
+ end
7
+
8
+ def create?
9
+ administrator?
10
+ end
11
+
12
+ def update?
13
+ administrator?
14
+ end
15
+
16
+ def new?
17
+ create?
18
+ end
19
+
20
+ def edit?
21
+ update?
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,43 @@
1
+ module Moduler
2
+ # Accepts the documents a user has not accepted yet. Every pending document
3
+ # must be among the accepted ids; otherwise nothing is accepted. Ids of
4
+ # unknown or already accepted documents are ignored.
5
+ class DocumentAcceptance
6
+ include ActiveModel::Validations
7
+
8
+ attr_reader :user, :document_ids
9
+
10
+ validate :all_pending_documents_accepted
11
+
12
+ def initialize(user, document_ids = [])
13
+ @user = user
14
+ @document_ids = Array(document_ids).compact_blank.map(&:to_i)
15
+ end
16
+
17
+ def pending_documents
18
+ @pending_documents ||= user.pending_documents.with_attached_file.order(:created_at).to_a
19
+ end
20
+
21
+ def accepted?(document)
22
+ document_ids.include?(document.id)
23
+ end
24
+
25
+ def call
26
+ return false if invalid?
27
+
28
+ UserDocument.insert_all(rows, unique_by: %i[ moduler_document_id moduler_user_id ])
29
+
30
+ true
31
+ end
32
+
33
+ private
34
+
35
+ def all_pending_documents_accepted
36
+ errors.add(:base, :not_all_accepted) if pending_documents.any? { |document| !accepted?(document) }
37
+ end
38
+
39
+ def rows
40
+ pending_documents.map { |document| { moduler_user_id: user.id, moduler_document_id: document.id } }
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,19 @@
1
+ <%= render(Moduler::ListItemComponent.new(document.name, id: dom_id(document))) do |item| %>
2
+ <% item.with_badge(t("moduler.administration.documents.kinds.#{document.kind}")) %>
3
+
4
+ <% item.with_meta do %>
5
+ <%= time_tag document.created_at, t(".created", time: time_ago_in_words(document.created_at)), title: l(document.created_at, format: :long) %>
6
+ <% end %>
7
+
8
+ <% if document.file.attached? %>
9
+ <% item.with_action do %>
10
+ <%= render(Moduler::ButtonLinkComponent.new(t(".action.download"), main_app.rails_blob_path(document.file, disposition: "attachment"), size: :sm)) %>
11
+ <% end %>
12
+ <% end %>
13
+
14
+ <% if Moduler::Administration::DocumentPolicy.new(current_user, document).edit? %>
15
+ <% item.with_action do %>
16
+ <%= render(Moduler::ButtonLinkComponent.new(t(".action.edit"), moduler.edit_administration_document_path(document), size: :sm)) %>
17
+ <% end %>
18
+ <% end %>
19
+ <% end %>
@@ -0,0 +1,23 @@
1
+ <%= render(Moduler::Administration::PageComponent.new) do %>
2
+ <%= render(Moduler::PageHeaderComponent.new(t(".title"))) %>
3
+
4
+ <%= form_with model: @document, url: @document.persisted? ? moduler.administration_document_path(@document) : moduler.administration_documents_path, data: { turbo: false }, class: "w-full space-y-6" do |form| %>
5
+ <%= render(::Moduler::Form::BaseErrorComponent.new(form)) %>
6
+
7
+ <div class="max-w-lg space-y-6">
8
+ <%= render(::Moduler::Form::TextComponent.new(form, :name, label: t(".label.name"))) %>
9
+ <%= render(::Moduler::Form::SelectComponent.new(form, :kind, Moduler::Document::KINDS.map { |kind| [ t("moduler.administration.documents.kinds.#{kind}"), kind ] }, label: t(".label.kind"))) %>
10
+ <% if @document.persisted? %>
11
+ <div class="mt-2">
12
+ <p class="block text-sm text-slate-500"><%= t(".label.file") %></p>
13
+ <p id="document_file" class="mt-2 text-sm text-gray-900"><%= @document.file.attached? ? @document.file.filename : t(".description.no_file") %></p>
14
+ <p class="mt-1 text-xs text-gray-400"><%= t(".description.file_locked") %></p>
15
+ </div>
16
+ <% else %>
17
+ <%= render(::Moduler::Form::FileComponent.new(form, :file, label: t(".label.file"), accept: "application/pdf", description: t(".description.file"))) %>
18
+ <% end %>
19
+ </div>
20
+
21
+ <%= render(::Moduler::Form::SubmitComponent.new(form, text: t(".action.save"))) %>
22
+ <% end %>
23
+ <% end %>
@@ -0,0 +1 @@
1
+ <%= render "form" %>
@@ -0,0 +1,13 @@
1
+ <%= render(Moduler::Administration::PageComponent.new) do %>
2
+ <%= render(Moduler::PageHeaderComponent.new(t(".title"), description: t(".description"))) do |header| %>
3
+ <% header.with_action do %>
4
+ <%= render(Moduler::ButtonLinkComponent.new(t(".action.new"), moduler.new_administration_document_path)) %>
5
+ <% end %>
6
+ <% end %>
7
+
8
+ <%= render(Moduler::FilterComponent.new(@filter, moduler.administration_documents_path)) %>
9
+
10
+ <%= render(Moduler::ListComponent.new(@documents, id: "documents", empty: t(".empty"))) do %>
11
+ <%= render partial: "moduler/administration/documents/document", collection: @documents %>
12
+ <% end %>
13
+ <% end %>
@@ -0,0 +1 @@
1
+ <%= render "form" %>
@@ -7,6 +7,7 @@
7
7
  <%= render(::Moduler::Form::SectionComponent.new(t(".section.data"))) do %>
8
8
  <%= render(::Moduler::Form::EmailComponent.new(form, :email_address, label: t(".label.email_address"))) %>
9
9
  <%= render(::Moduler::Form::SelectComponent.new(form, :role, Moduler::User.roles.keys.map { |role| [ t("moduler.administration.users.roles.#{role}"), role ] }, label: t(".label.role"))) %>
10
+ <%= render(::Moduler::Form::SelectComponent.new(form, :status, Moduler::User.statuses.keys.map { |status| [ t("moduler.administration.users.statuses.#{status}"), status ] }, label: t(".label.status"))) %>
10
11
  <% end %>
11
12
 
12
13
  <%= render(::Moduler::Form::SubmitComponent.new(form, text: t(".action.save"))) %>
@@ -7,6 +7,7 @@
7
7
  <%= render(::Moduler::Form::SectionComponent.new(t(".section.data"))) do %>
8
8
  <%= render(::Moduler::Form::EmailComponent.new(form, :email_address, label: t(".label.email_address"))) %>
9
9
  <%= render(::Moduler::Form::SelectComponent.new(form, :role, Moduler::User.roles.keys.map { |role| [ t("moduler.administration.users.roles.#{role}"), role ] }, label: t(".label.role"))) %>
10
+ <%= render(::Moduler::Form::SelectComponent.new(form, :status, Moduler::User.statuses.keys.map { |status| [ t("moduler.administration.users.statuses.#{status}"), status ] }, label: t(".label.status"))) %>
10
11
  <% end %>
11
12
 
12
13
  <%= render(::Moduler::Form::SectionComponent.new(t(".section.password"), divider: true)) do %>
@@ -4,6 +4,7 @@
4
4
  <%= render(Moduler::SidebarActionComponent.new(t(".action.sessions"), moduler.administration_sessions_path, "moduler/administration/sessions")) %>
5
5
 
6
6
  <%= render(Moduler::SidebarActionComponent.new(t(".action.articles"), moduler.administration_articles_path, "moduler/administration/articles")) %>
7
+ <%= render(Moduler::SidebarActionComponent.new(t(".action.documents"), moduler.administration_documents_path, "moduler/administration/documents")) %>
7
8
 
8
9
  <% Array(Moduler.configuration.dig(:administration, :resources)).each do |resource| %>
9
10
  <%= render(Moduler::SidebarActionComponent.new(t(".action.#{resource}"), moduler.url_for([:administration, resource.to_sym]), "moduler/administration/#{resource}")) %>
@@ -0,0 +1,36 @@
1
+ <main class="mx-auto flex w-full max-w-2xl flex-1 flex-col px-4 py-8">
2
+ <div class="mb-6">
3
+ <h1 class="text-base font-semibold text-gray-900"><%= t(".title") %></h1>
4
+ <p class="mt-1 text-sm text-slate-500"><%= t(".description") %></p>
5
+ </div>
6
+
7
+ <%= form_with url: moduler.document_acceptance_path, scope: :document_acceptance, data: { turbo: false }, class: "space-y-6" do |form| %>
8
+ <ul id="documents" class="divide-y divide-gray-200 rounded-md border border-gray-200 bg-white">
9
+ <% @document_acceptance.pending_documents.each do |document| %>
10
+ <li id="<%= dom_id(document) %>" class="flex items-start gap-3 px-4 py-4 sm:px-5">
11
+ <%= check_box_tag "document_acceptance[document_ids][]", document.id, @document_acceptance.accepted?(document),
12
+ id: "document_acceptance_document_#{document.id}", class: "mt-0.5 h-4 w-4 shrink-0 cursor-pointer rounded border-gray-300 accent-custom" %>
13
+
14
+ <div class="min-w-0 flex-1">
15
+ <label for="document_acceptance_document_<%= document.id %>" class="cursor-pointer text-sm text-gray-900">
16
+ <%= t(".accept", name: document.name) %>
17
+ </label>
18
+ <p class="mt-1 text-xs text-slate-400">
19
+ <%= t("moduler.administration.documents.kinds.#{document.kind}") %>
20
+ <% if document.file.attached? %>
21
+ ·
22
+ <%= link_to t(".action.read"), main_app.rails_blob_path(document.file, disposition: "inline"), target: "_blank", rel: "noopener", class: "font-semibold text-custom hover:underline" %>
23
+ <% end %>
24
+ </p>
25
+ </div>
26
+ </li>
27
+ <% end %>
28
+ </ul>
29
+
30
+ <% @document_acceptance.errors.full_messages.each do |message| %>
31
+ <p class="text-sm text-red-500"><%= message %></p>
32
+ <% end %>
33
+
34
+ <%= render(::Moduler::Form::SubmitComponent.new(form, text: t(".action.submit"))) %>
35
+ <% end %>
36
+ </main>
@@ -0,0 +1,35 @@
1
+ en:
2
+ moduler:
3
+ administration:
4
+ documents:
5
+ kinds:
6
+ privacy_policy: Privacy policy
7
+ terms_and_conditions: Terms and conditions
8
+ data_processing_agreement: Data processing agreement
9
+ index:
10
+ title: Documents
11
+ description: Legal documents your users accept, such as the privacy policy or terms and conditions
12
+ empty: No documents found
13
+ action:
14
+ new: Create
15
+ document:
16
+ created: Created %{time} ago
17
+ action:
18
+ download: Download
19
+ edit: Edit
20
+ create:
21
+ notice: Document has been created
22
+ update:
23
+ notice: Document has been updated
24
+ form:
25
+ title: Document
26
+ label:
27
+ name: Name
28
+ kind: Kind
29
+ file: File
30
+ description:
31
+ file: PDF, smaller than 10 MB. The file cannot be changed after the document is created.
32
+ file_locked: The file cannot be changed.
33
+ no_file: No file
34
+ action:
35
+ save: Save
@@ -5,6 +5,10 @@ en:
5
5
  roles:
6
6
  member: Member
7
7
  administrator: Administrator
8
+ statuses:
9
+ waitlisted: Waitlisted
10
+ active: Active
11
+ inactive: Inactive
8
12
  index:
9
13
  title: Users
10
14
  description: Manage user accounts and their roles. Search users by email address.
@@ -16,6 +20,7 @@ en:
16
20
  label:
17
21
  email_address: Email address
18
22
  role: Role
23
+ status: Status
19
24
  password: Password
20
25
  description:
21
26
  password: Use at least 8 characters.
@@ -31,6 +36,7 @@ en:
31
36
  label:
32
37
  email_address: Email address
33
38
  role: Role
39
+ status: Status
34
40
  section:
35
41
  data: User data
36
42
  action:
@@ -7,6 +7,7 @@ en:
7
7
  users: Users
8
8
  sessions: Sessions
9
9
  articles: Articles
10
+ documents: Documents
10
11
  profile:
11
12
  sidebar:
12
13
  action:
@@ -0,0 +1,17 @@
1
+ en:
2
+ activemodel:
3
+ errors:
4
+ models:
5
+ moduler/document_acceptance:
6
+ not_all_accepted: Accept all documents to continue.
7
+ moduler:
8
+ document_acceptances:
9
+ new:
10
+ title: Before you continue
11
+ description: Read and accept the documents below to get access to the panel.
12
+ accept: "I have read and accept: %{name}"
13
+ action:
14
+ read: Read the document
15
+ submit: Accept and continue
16
+ create:
17
+ notice: Thank you. You can now use the panel.
@@ -15,4 +15,5 @@ en:
15
15
  password: Enter your password
16
16
  create:
17
17
  invalid_credentials: Invalid credentials
18
+ inactive_account: Your account does not have access to the panel.
18
19
  rate_limited: Too many sign-in attempts. Please try again in a few minutes.
data/config/routes.rb CHANGED
@@ -4,6 +4,8 @@ Moduler::Engine.routes.draw do
4
4
  resource :session, only: %i[new create destroy]
5
5
  resource :user, only: %i[new create]
6
6
 
7
+ resource :document_acceptance, only: %i[ new create ]
8
+
7
9
  resource :profile, only: %i[ edit update ], controller: :profile
8
10
  namespace :profile do
9
11
  namespace :sessions do
@@ -17,6 +19,7 @@ Moduler::Engine.routes.draw do
17
19
  resources :sessions, only: %i[ index destroy ]
18
20
 
19
21
  resources :articles
22
+ resources :documents, only: %i[ index new create edit update ]
20
23
 
21
24
  root "home#index"
22
25
  end
@@ -0,0 +1,6 @@
1
+ class AddStatusToModulerUsers < ActiveRecord::Migration[8.1]
2
+ def change
3
+ add_column :moduler_users, :status, :integer, default: 1, null: false
4
+ add_index :moduler_users, :status
5
+ end
6
+ end
@@ -0,0 +1,10 @@
1
+ class CreateModulerDocuments < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :moduler_documents do |t|
4
+ t.string :name, null: false
5
+ t.string :kind, null: false
6
+
7
+ t.timestamps null: false
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,12 @@
1
+ class CreateModulerUserDocuments < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :moduler_user_documents do |t|
4
+ t.references :moduler_user, type: :uuid, null: false, foreign_key: true
5
+ t.references :moduler_document, null: false, foreign_key: true, index: false
6
+
7
+ t.timestamps
8
+ end
9
+
10
+ add_index :moduler_user_documents, %i[ moduler_document_id moduler_user_id ], unique: true
11
+ end
12
+ end
@@ -1,3 +1,3 @@
1
1
  module Moduler
2
- VERSION = "0.4.0"
2
+ VERSION = "0.5.0"
3
3
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: moduler
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tomasz Kowalewski
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-10-27 00:00:00.000000000 Z
10
+ date: 2026-11-04 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: rails
@@ -238,6 +238,8 @@ files:
238
238
  - app/components/moduler/form/base_error_component.rb
239
239
  - app/components/moduler/form/email_component.html.erb
240
240
  - app/components/moduler/form/email_component.rb
241
+ - app/components/moduler/form/file_component.html.erb
242
+ - app/components/moduler/form/file_component.rb
241
243
  - app/components/moduler/form/password_component.html.erb
242
244
  - app/components/moduler/form/password_component.rb
243
245
  - app/components/moduler/form/section_component.html.erb
@@ -261,13 +263,16 @@ files:
261
263
  - app/components/moduler/sidebar_action_component.html.erb
262
264
  - app/components/moduler/sidebar_action_component.rb
263
265
  - app/controllers/concerns/moduler/authentication.rb
266
+ - app/controllers/concerns/moduler/require_document_acceptance.rb
264
267
  - app/controllers/moduler/administration/application_controller.rb
265
268
  - app/controllers/moduler/administration/articles_controller.rb
269
+ - app/controllers/moduler/administration/documents_controller.rb
266
270
  - app/controllers/moduler/administration/home_controller.rb
267
271
  - app/controllers/moduler/administration/sessions_controller.rb
268
272
  - app/controllers/moduler/administration/users_controller.rb
269
273
  - app/controllers/moduler/application_controller.rb
270
274
  - app/controllers/moduler/articles_controller.rb
275
+ - app/controllers/moduler/document_acceptances_controller.rb
271
276
  - app/controllers/moduler/home_controller.rb
272
277
  - app/controllers/moduler/profile/sessions/revocation_controller.rb
273
278
  - app/controllers/moduler/profile/sessions_controller.rb
@@ -283,14 +288,18 @@ files:
283
288
  - app/models/moduler/application_record.rb
284
289
  - app/models/moduler/article.rb
285
290
  - app/models/moduler/current.rb
291
+ - app/models/moduler/document.rb
286
292
  - app/models/moduler/session.rb
287
293
  - app/models/moduler/user.rb
294
+ - app/models/moduler/user_document.rb
288
295
  - app/policies/moduler/administration/article_policy.rb
289
296
  - app/policies/moduler/administration/base_policy.rb
297
+ - app/policies/moduler/administration/document_policy.rb
290
298
  - app/policies/moduler/administration/home_policy.rb
291
299
  - app/policies/moduler/administration/session_policy.rb
292
300
  - app/policies/moduler/administration/user_policy.rb
293
301
  - app/policies/moduler/application_policy.rb
302
+ - app/services/moduler/document_acceptance.rb
294
303
  - app/views/kaminari/_gap.html.erb
295
304
  - app/views/kaminari/_next_page.html.erb
296
305
  - app/views/kaminari/_page.html.erb
@@ -303,6 +312,11 @@ files:
303
312
  - app/views/moduler/administration/articles/edit.html.erb
304
313
  - app/views/moduler/administration/articles/index.html.erb
305
314
  - app/views/moduler/administration/articles/new.html.erb
315
+ - app/views/moduler/administration/documents/_document.html.erb
316
+ - app/views/moduler/administration/documents/_form.html.erb
317
+ - app/views/moduler/administration/documents/edit.html.erb
318
+ - app/views/moduler/administration/documents/index.html.erb
319
+ - app/views/moduler/administration/documents/new.html.erb
306
320
  - app/views/moduler/administration/home/index.html.erb
307
321
  - app/views/moduler/administration/sessions/_session.html.erb
308
322
  - app/views/moduler/administration/sessions/index.html.erb
@@ -318,6 +332,7 @@ files:
318
332
  - app/views/moduler/common/_sidebar.html.erb
319
333
  - app/views/moduler/common/administration/_sidebar.html.erb
320
334
  - app/views/moduler/common/profile/_sidebar.html.erb
335
+ - app/views/moduler/document_acceptances/new.html.erb
321
336
  - app/views/moduler/home/index.html.erb
322
337
  - app/views/moduler/profile/edit.html.erb
323
338
  - app/views/moduler/profile/sessions/_session.html.erb
@@ -326,11 +341,13 @@ files:
326
341
  - app/views/moduler/users/new.html.erb
327
342
  - config/importmap.rb
328
343
  - config/locales/moduler/administration/articles/en.yml
344
+ - config/locales/moduler/administration/documents/en.yml
329
345
  - config/locales/moduler/administration/sessions/en.yml
330
346
  - config/locales/moduler/administration/users/en.yml
331
347
  - config/locales/moduler/articles/en.yml
332
348
  - config/locales/moduler/common/en.yml
333
349
  - config/locales/moduler/components/en.yml
350
+ - config/locales/moduler/document_acceptances/en.yml
334
351
  - config/locales/moduler/en.yml
335
352
  - config/locales/moduler/profile/en.yml
336
353
  - config/locales/moduler/profile/sessions/en.yml
@@ -342,6 +359,9 @@ files:
342
359
  - db/migrate/20261015171504_add_role_to_moduler_users.rb
343
360
  - db/migrate/20261020191709_enable_pg_trgm.rb
344
361
  - db/migrate/20261026203827_create_moduler_articles.rb
362
+ - db/migrate/20261104120000_add_status_to_moduler_users.rb
363
+ - db/migrate/20261104205837_create_moduler_documents.rb
364
+ - db/migrate/20261104205950_create_moduler_user_documents.rb
345
365
  - lib/moduler.rb
346
366
  - lib/moduler/configuration.rb
347
367
  - lib/moduler/engine.rb