maquina 0.5.2 → 0.7.2

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 (94) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -1
  3. data/Gemfile +2 -0
  4. data/Gemfile.lock +3 -35
  5. data/app/assets/javascripts/controllers/alert_controller.js +22 -0
  6. data/app/assets/stylesheets/maquina/application.tailwind.css +4 -101
  7. data/app/assets/stylesheets/maquina.css +114 -0
  8. data/app/controllers/concerns/maquina/authenticate.rb +29 -1
  9. data/app/controllers/concerns/maquina/index.rb +81 -2
  10. data/app/controllers/concerns/maquina/resourceful.rb +95 -9
  11. data/app/controllers/maquina/application_controller.rb +1 -1
  12. data/app/controllers/maquina/dashboard_controller.rb +10 -5
  13. data/app/controllers/maquina/invitations_controller.rb +0 -2
  14. data/app/controllers/maquina/plans_controller.rb +13 -23
  15. data/app/controllers/maquina/sessions_controller.rb +1 -1
  16. data/app/controllers/maquina/users_controller.rb +0 -2
  17. data/app/helpers/maquina/application_helper.rb +0 -8
  18. data/app/helpers/maquina/navbar_menu_helper.rb +1 -1
  19. data/app/models/concerns/maquina/blockeable.rb +64 -0
  20. data/app/models/concerns/maquina/organization_scoped.rb +26 -0
  21. data/app/models/concerns/maquina/retain_passwords.rb +44 -0
  22. data/app/models/concerns/maquina/sqlite_search.rb +92 -0
  23. data/app/models/concerns/maquina/user_scoped.rb +26 -0
  24. data/app/models/maquina/active_session.rb +40 -0
  25. data/app/models/maquina/current.rb +39 -2
  26. data/app/models/maquina/invitation.rb +28 -0
  27. data/app/models/maquina/membership.rb +23 -1
  28. data/app/models/maquina/organization.rb +19 -2
  29. data/app/models/maquina/plan.rb +26 -6
  30. data/app/models/maquina/used_password.rb +30 -0
  31. data/app/models/maquina/user.rb +50 -8
  32. data/app/policies/maquina/application_policy.rb +1 -1
  33. data/app/policies/maquina/dashboard_policy.rb +7 -0
  34. data/app/policies/maquina/navigation_policy.rb +2 -0
  35. data/app/views/layouts/maquina/application.html.erb +2 -2
  36. data/app/views/layouts/maquina/sessions.html.erb +1 -1
  37. data/app/views/maquina/application/_navbar.html.erb +8 -3
  38. data/app/views/maquina/application/components/checkbox_component.rb +3 -3
  39. data/app/views/maquina/application/components/component_base.rb +3 -2
  40. data/app/views/maquina/application/edit.html.erb +10 -7
  41. data/app/views/maquina/application/filters.rb +118 -0
  42. data/app/views/maquina/application/index.html.erb +6 -3
  43. data/app/views/maquina/application/index_header.rb +5 -2
  44. data/app/views/maquina/application/index_table.rb +71 -6
  45. data/app/views/maquina/application/index_tools.rb +17 -0
  46. data/app/views/maquina/application/new.html.erb +10 -7
  47. data/app/views/maquina/application/search.rb +42 -0
  48. data/app/views/maquina/application/sessions_header.rb +3 -9
  49. data/app/views/maquina/dashboard/index.html.erb +4 -0
  50. data/app/views/maquina/dashboard/stats.rb +35 -0
  51. data/app/views/maquina/dashboard/tasks.rb +124 -0
  52. data/app/views/maquina/first_runs/form.rb +0 -2
  53. data/app/views/maquina/first_runs/show.html.erb +4 -1
  54. data/app/views/maquina/navbar/title.rb +4 -2
  55. data/config/importmap.rb +1 -13
  56. data/config/locales/flash.en.yml +6 -0
  57. data/config/locales/flash.es.yml +6 -0
  58. data/config/locales/forms.en.yml +33 -4
  59. data/config/locales/forms.es.yml +22 -11
  60. data/config/locales/models.en.yml +10 -0
  61. data/config/locales/models.es.yml +10 -0
  62. data/config/locales/views.en.yml +33 -5
  63. data/config/locales/views.es.yml +28 -10
  64. data/config/routes.rb +1 -0
  65. data/db/migrate/20221109010726_create_maquina_plans.rb +1 -1
  66. data/db/migrate/20221113000409_create_maquina_users.rb +1 -1
  67. data/db/migrate/20221113020108_create_maquina_used_passwords.rb +1 -1
  68. data/db/migrate/20221115223414_create_maquina_active_sessions.rb +1 -3
  69. data/db/migrate/20230201203922_create_maquina_invitations.rb +1 -1
  70. data/db/migrate/20230829183530_create_maquina_organizations.rb +1 -1
  71. data/db/migrate/20230829192656_create_maquina_memberships.rb +2 -4
  72. data/db/migrate/20241109191405_add_counter_cache_to_plans.rb +5 -0
  73. data/lib/generators/maquina/install_generator.rb +67 -1
  74. data/lib/generators/maquina/tailwind_config/templates/lib/generators/tailwind_config/templates/config/tailwind.config.js.tt +9 -5
  75. data/lib/generators/maquina/tailwind_config/templates/lib/tasks/tailwind.rake.tt +2 -0
  76. data/lib/generators/maquina/templates/config/initializers/maquina.rb.tt +2 -1
  77. data/lib/maquina/engine.rb +6 -3
  78. data/lib/maquina/version.rb +1 -1
  79. data/lib/maquina.rb +2 -9
  80. metadata +20 -76
  81. data/app/assets/javascripts/maquina/application.js +0 -4
  82. data/app/assets/javascripts/maquina/controllers/alert_controller.js +0 -29
  83. data/app/assets/javascripts/maquina/controllers/application.js +0 -9
  84. data/app/assets/javascripts/maquina/controllers/index.js +0 -11
  85. data/app/models/concerns/maquina/authenticate_by.rb +0 -33
  86. data/app/views/maquina/navbar/search.rb +0 -40
  87. data/lib/generators/maquina/install_templates/install_templates_generator.rb +0 -31
  88. /data/app/assets/javascripts/{maquina/controllers → controllers}/backdrop_controller.js +0 -0
  89. /data/app/assets/javascripts/{maquina/controllers → controllers}/file_controller.js +0 -0
  90. /data/app/assets/javascripts/{maquina/controllers → controllers}/mobile_menu_controller.js +0 -0
  91. /data/app/assets/javascripts/{maquina/controllers → controllers}/modal_controller.js +0 -0
  92. /data/app/assets/javascripts/{maquina/controllers → controllers}/modal_open_controller.js +0 -0
  93. /data/app/assets/javascripts/{maquina/controllers → controllers}/popup_menu_controller.js +0 -0
  94. /data/app/assets/javascripts/{maquina/controllers → controllers}/submit_form_controller.js +0 -0
@@ -1,30 +1,92 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Maquina
4
+ ##
5
+ # The Resourceful concern provides RESTful controller actions and helper methods
6
+ # for Rails controllers. It implements common CRUD operations and follows
7
+ # Rails conventions while allowing customization of actions, attributes, and policies.
8
+ #
9
+ # Example:
10
+ # class PlansController < ApplicationController
11
+ # resourceful(
12
+ # resource_class: Plan,
13
+ # form_attributes: [{name: {type: :input}}],
14
+ # list_attributes: [:name, :price],
15
+ # policy_class: PlanPolicy
16
+ # )
17
+ # end
18
+ #
4
19
  module Resourceful
5
20
  extend ActiveSupport::Concern
6
21
 
22
+ ##
23
+ # Internal class to handle success/failure response blocks in dual actions
7
24
  class ResourceResponse
25
+ # Stores the response block
8
26
  def response(&block)
9
27
  @block = block
10
28
  end
11
29
 
30
+ ##
31
+ # Returns the stored block
12
32
  def code
13
33
  @block
14
34
  end
15
35
  end
16
36
 
17
37
  included do
38
+ ##
39
+ # The model class this controller manages
18
40
  class_attribute :resource_class, instance_writer: false
41
+
42
+ ##
43
+ # Optional namespace for route generation
19
44
  class_attribute :namespace, instance_writer: false
45
+
46
+ ##
47
+ # The parameter to use for finding resources (defaults to :id)
20
48
  class_attribute :find_by_param, instance_writer: false
49
+
50
+ ##
51
+ # Attributes to display in index views. It is a list of symbols.
21
52
  class_attribute :list_attributes, instance_writer: false
53
+
54
+ ##
55
+ # Attributes for form rendering with their configurations. It is a list of hashes.
56
+ # Each hash must contain a key for the attribute name and a value for the configuration.
57
+ # The configuration can be a hash with the following keys:
58
+ # - :type: The type of input to render. It can be :input, :checkbox, :textarea
59
+ # - :control_html: HTML attributes for the input control. It is a hash.
60
+ # - :input_html: HTML attributes for the input. It is a hash.
22
61
  class_attribute :form_attributes, instance_writer: false
62
+
63
+ ##
64
+ # Attributes to display in show views. It is a list of symbols.
23
65
  class_attribute :show_attributes, instance_writer: false
66
+
67
+ ##
68
+ # The ActionPolicy class for authorization
24
69
  class_attribute :policy_class, instance_writer: false
70
+
71
+ ##
72
+ # Whether to show the resource link in the index view
25
73
  class_attribute :show_link, instance_writer: false
26
74
 
27
- attr_reader :resource, :collection
75
+ ##
76
+ # The filters to apply to the index action
77
+ class_attribute :filters, instance_writer: false
78
+
79
+ ##
80
+ # The sortable attributes for the index action
81
+ class_attribute :sortable, instance_writer: false
82
+
83
+ ##
84
+ # The current resource instance
85
+ attr_reader :resource
86
+
87
+ ##
88
+ # The resource collection for index action
89
+ attr_reader :collection
28
90
 
29
91
  protected
30
92
 
@@ -39,22 +101,32 @@ module Maquina
39
101
  end
40
102
 
41
103
  def base_plural_path_name
104
+ return nil if resource_class.nil?
42
105
  @base_plural_path_name ||= "#{namespace_path}#{resource_class.model_name.route_key}_path"
43
106
  end
44
107
 
108
+ ##
109
+ # Generates the path for a new resource
45
110
  def new_resource_path(options = {})
46
- Rails.application.routes.url_helpers.send("new_#{base_singular_path_name}", **options)
111
+ Rails.application.routes.url_helpers.send(:"new_#{base_singular_path_name}", **options)
47
112
  end
48
113
 
114
+ ##
115
+ # Generates the edit path for a resource
49
116
  def edit_resource_path(resource, options = {})
50
- Rails.application.routes.url_helpers.send("edit_#{base_singular_path_name}", resource, **options)
117
+ Rails.application.routes.url_helpers.send(:"edit_#{base_singular_path_name}", resource, **options)
51
118
  end
52
119
 
120
+ ##
121
+ # Generates the path for a resource
53
122
  def resource_path(resource)
54
123
  Rails.application.routes.url_helpers.send(base_singular_path_name, resource)
55
124
  end
56
125
 
126
+ ##
127
+ # Generates the collection path
57
128
  def collection_path(params = {})
129
+ return nil if resource_class.nil?
58
130
  Rails.application.routes.url_helpers.send(base_plural_path_name, **params)
59
131
  end
60
132
 
@@ -64,7 +136,7 @@ module Maquina
64
136
  # Controller secure params
65
137
  def resource_secure_params
66
138
  method_name = :secure_params
67
- method_action_name = "#{action_name}_#{method_name}".to_sym
139
+ method_action_name = :"#{action_name}_#{method_name}"
68
140
 
69
141
  if respond_to?(method_action_name, true)
70
142
  send(method_action_name)
@@ -96,7 +168,7 @@ module Maquina
96
168
 
97
169
  success = ResourceResponse.new
98
170
  failure = ResourceResponse.new
99
- block.call success, failure
171
+ yield success, failure
100
172
 
101
173
  if has_errors
102
174
  failure.code.call
@@ -108,7 +180,7 @@ module Maquina
108
180
  responded = true
109
181
 
110
182
  success = ResourceResponse.new
111
- block.call success
183
+ yield success
112
184
 
113
185
  success.code.call
114
186
  end
@@ -118,7 +190,6 @@ module Maquina
118
190
  respond_to do |format|
119
191
  if has_errors
120
192
  format.html { render object.persisted? ? :edit : :new }
121
- # format.turbo_stream
122
193
  format.json { render json: {errors: object.errors.map { |error| {error.attribute => error.message} }} }
123
194
  else
124
195
  format.html { redirect_to collection_path, status: :see_other }
@@ -138,16 +209,31 @@ module Maquina
138
209
  end
139
210
 
140
211
  helper_method :resource_class, :policy_class, :resource, :list_attributes, :form_attributes, :collection,
141
- :collection_path, :resource_path, :new_resource_path, :edit_resource_path, :submit_path, :show_link
212
+ :collection_path, :resource_path, :new_resource_path, :edit_resource_path, :submit_path, :show_link, :filters,
213
+ :sortable
142
214
  end
143
215
 
144
216
  class_methods do
145
- def resourceful(resource_class: nil, namespace: nil, find_by_param: :id, only: [], except: [], list_attributes: [], form_attributes: [], show_attributes: [], policy_class: nil, show_link: nil)
217
+ ##
218
+ # Configures the resourceful behavior for the controller
219
+ #
220
+ # Example:
221
+ # resourceful(
222
+ # resource_class: User,
223
+ # list_attributes: [:email, :created_at],
224
+ # policy_class: UserPolicy,
225
+ # only: [:index, :show]
226
+ # )
227
+ def resourceful(resource_class: nil, namespace: nil, find_by_param: :id, only: [], except: [],
228
+ list_attributes: [], form_attributes: [], show_attributes: [], policy_class: nil, show_link: nil, filters: {},
229
+ sortable: [])
146
230
  self.resource_class = resource_class || controller_path.classify.safe_constantize
147
231
  self.find_by_param = find_by_param || :id
148
232
  self.list_attributes = Array(list_attributes).compact
149
233
  self.form_attributes = Array(form_attributes).compact
150
234
  self.show_attributes = Array(show_attributes).compact
235
+ self.filters = filters
236
+ self.sortable = sortable
151
237
  self.policy_class = policy_class
152
238
  self.show_link = show_link
153
239
  self.namespace = namespace
@@ -13,7 +13,7 @@ module Maquina
13
13
  private
14
14
 
15
15
  def not_authorized
16
- redirect_to unauthorized_path
16
+ redirect_to unauthorized_path, status: :see_other
17
17
  end
18
18
  end
19
19
  end
@@ -2,15 +2,20 @@
2
2
 
3
3
  module Maquina
4
4
  class DashboardController < ApplicationController
5
- before_action :authenticate!
6
-
7
5
  def index
6
+ if Maquina::Current.signed_in? && Maquina::Current.management?
7
+ @stats = [
8
+ {label: t("maquina.dashboard.index.stats.total_plans"), value: Maquina::Plan.count},
9
+ {label: t("maquina.dashboard.index.stats.total_organizations"), value: Maquina::Organization.count}
10
+ ]
11
+ end
8
12
  end
9
13
 
10
- protected
14
+ private
11
15
 
12
- def collection_path
13
- nil
16
+ def policy_class
17
+ Maquina::DashboardPolicy
14
18
  end
19
+ helper_method :policy_class
15
20
  end
16
21
  end
@@ -2,8 +2,6 @@
2
2
 
3
3
  module Maquina
4
4
  class InvitationsController < ApplicationController
5
- before_action :authenticate!
6
-
7
5
  layout false
8
6
 
9
7
  resourceful(
@@ -2,15 +2,6 @@
2
2
 
3
3
  module Maquina
4
4
  class PlansController < ApplicationController
5
- # include Maquina::Resourceful # Include this module if your ApplicationController does not include it.
6
- # include Maquina::Authenticate # Include this module if your ApplicationController does not include it.
7
- before_action :authenticate!
8
-
9
- # resource_class: Model Name
10
- # form_attributes: List of attributes for edit with type
11
- # list_attributes: List of attributes to display in index action
12
- # show_attributes: List of attributes to display in show action
13
- # policy_class: ActionPolicy class to check action authorization. Update this policy file.
14
5
  resourceful(
15
6
  resource_class: Maquina::Plan,
16
7
  form_attributes: [{name: {type: :input, control_html: {class: "sm:col-span-6"}, input_html: {class: "block w-full min-w-0 flex-1 input"}}},
@@ -18,18 +9,13 @@ module Maquina
18
9
  {price: {type: :input, control_html: {class: "sm:col-span-2"}, input_html: {class: "block w-full min-w-0 flex-1 input"}}},
19
10
  {free: {type: :checkbox, control_html: {class: "sm:col-span-6 relative flex items-start"}, input_html: {class: "check"}}},
20
11
  {active: {type: :checkbox, control_html: {class: "sm:col-span-6 relative flex items-start"}, input_html: {class: "check"}}}],
21
- list_attributes: [:name, :trial, :price, :free, :active],
22
- show_attributes: [:name, :trial, :price, :free, :active],
12
+ list_attributes: [:name, :organizations_count, :trial, :price, :free, :active, :updated_at],
23
13
  policy_class: Maquina::PlanPolicy,
14
+ filters: {free: {Yes: true, No: false}},
15
+ sortable: [:name, :price, :updated_at, :organizations_count],
24
16
  except: [:show, :destroy]
25
17
  )
26
18
 
27
- def create
28
- create! do |success|
29
- success.response { redirect_to edit_plan_path(resource), status: :see_other }
30
- end
31
- end
32
-
33
19
  private
34
20
 
35
21
  def new_resource_path(options = {})
@@ -40,17 +26,21 @@ module Maquina
40
26
  edit_plan_path(resource, **options)
41
27
  end
42
28
 
43
- def resource_path(resource)
44
- plan_path(resource)
29
+ def resource_path(resource, options = {})
30
+ plan_path(resource, **options)
31
+ end
32
+
33
+ def collection_path(params = {})
34
+ plans_path(**params)
45
35
  end
46
36
 
47
- def collection_path
48
- plans_path
37
+ def before_order_by(attribute, direction)
38
+ attribute = :price_cents if attribute == :price
39
+ [attribute, direction]
49
40
  end
50
41
 
51
- # Only allow a list of trusted parameters through.
52
42
  def secure_params
53
- params.require(:maquina_plan).permit(:name, :trial, :price, :free, :active)
43
+ params.require(:plan).permit(:name, :trial, :price, :free, :active)
54
44
  end
55
45
  end
56
46
  end
@@ -34,7 +34,7 @@ module Maquina
34
34
  Maquina::Current.reset
35
35
 
36
36
  flash.notice = t("flash.sessions.destroy.notice")
37
- redirect_to main_app.sign_in_path, status: :see_other
37
+ redirect_to main_app.root_path, status: :see_other
38
38
  end
39
39
 
40
40
  private
@@ -2,8 +2,6 @@
2
2
 
3
3
  module Maquina
4
4
  class UsersController < ApplicationController
5
- before_action :authenticate!
6
-
7
5
  resourceful(
8
6
  resource_class: User,
9
7
  list_attributes: [:email, :blocked_at, :created_at],
@@ -2,14 +2,6 @@
2
2
 
3
3
  module Maquina
4
4
  module ApplicationHelper
5
- def maquina_importmap_tags(entry_point = "application", importmap: Maquina.configuration.importmap)
6
- safe_join [
7
- javascript_inline_importmap_tag(importmap.to_json(resolver: self)),
8
- javascript_importmap_module_preload_tags(importmap),
9
- javascript_import_module_tag(entry_point)
10
- ].compact, "\n"
11
- end
12
-
13
5
  def class_to_form_frame(klass)
14
6
  "#{klass.to_s.underscore.tr("/", "_")}_form"
15
7
  end
@@ -11,7 +11,7 @@ module Maquina
11
11
  end
12
12
 
13
13
  def active_menu_option?(path)
14
- request.path == path
14
+ request.path.match?(path)
15
15
  end
16
16
 
17
17
  def profile_menu_options
@@ -1,6 +1,70 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Maquina
4
+ ##
5
+ # A concern that implements blocking functionality for models.
6
+ # Supports both permanent and temporary blocking mechanisms.
7
+ #
8
+ # == Usage
9
+ #
10
+ # Include this concern in models that need blocking capability:
11
+ #
12
+ # class User < ApplicationRecord
13
+ # include Maquina::Blockeable
14
+ # end
15
+ #
16
+ # == Required Attributes
17
+ #
18
+ # Models must have one or both of:
19
+ # - +blocked_at+:: Timestamp for permanent blocks
20
+ # - +temporary_blocked_at+:: Timestamp for temporary blocks
21
+ #
22
+ # == Configuration
23
+ #
24
+ # Temporary blocking duration is controlled by:
25
+ # - +Maquina.configuration.temporary_block+:: Duration of temporary blocks
26
+ #
27
+ # == Special Behavior for User Model
28
+ #
29
+ # When included in Maquina::User:
30
+ # - Adds +memberships_blocked?+ method to check if all user's memberships are blocked
31
+ # - Excludes management users from membership blocking checks
32
+ #
33
+ # == Scopes
34
+ #
35
+ # === unblocked
36
+ #
37
+ # Returns records that are not blocked (either permanently or temporarily)
38
+ #
39
+ # With both blocked_at and temporary_blocked_at:
40
+ # scope :unblocked, -> { where(blocked_at: nil).where("(coalesce(temporary_blocked_at + interval '? minutes', now())) <= now()", ...) }
41
+ #
42
+ # With only blocked_at:
43
+ # scope :unblocked, -> { where(blocked_at: nil) }
44
+ #
45
+ # == Instance Methods
46
+ #
47
+ # === blocked?
48
+ #
49
+ # Returns true if the record is blocked by any mechanism:
50
+ # - Has permanent block (blocked_at present)
51
+ # - Has active temporary block
52
+ # - All memberships are blocked (User model only)
53
+ #
54
+ # === memberships_blocked?
55
+ #
56
+ # Only available for User model. Returns true if user has no active unblocked memberships.
57
+ #
58
+ # == Example
59
+ #
60
+ # class User < ApplicationRecord
61
+ # include Maquina::Blockeable
62
+ # end
63
+ #
64
+ # user.update(blocked_at: Time.current)
65
+ # user.blocked? # => true
66
+ # User.unblocked # => excludes blocked users
67
+ #
4
68
  module Blockeable
5
69
  extend ActiveSupport::Concern
6
70
 
@@ -1,6 +1,32 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Maquina
4
+ ##
5
+ # A concern that adds organization association to models.
6
+ #
7
+ # == Usage
8
+ #
9
+ # Include this concern in any model that needs to be scoped to an organization:
10
+ #
11
+ # class MyModel < ApplicationRecord
12
+ # include Maquina::OrganizationScoped
13
+ # end
14
+ #
15
+ # == Associations
16
+ #
17
+ # When included, automatically adds:
18
+ # - +organization+:: Belongs to an Organization through maquina_organization_id foreign key
19
+ #
20
+ # == Example
21
+ #
22
+ # class Project < ApplicationRecord
23
+ # include Maquina::OrganizationScoped
24
+ # # Project now has organization association
25
+ # end
26
+ #
27
+ # project = Project.create(organization: current_organization)
28
+ # project.organization # => returns associated Maquina::Organization
29
+ #
4
30
  module OrganizationScoped
5
31
  extend ActiveSupport::Concern
6
32
 
@@ -1,6 +1,43 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Maquina
4
+ ##
5
+ # A concern that implements password history and reuse prevention.
6
+ #
7
+ # == Usage
8
+ #
9
+ # Include this concern in models that need password history tracking:
10
+ #
11
+ # class User < ApplicationRecord
12
+ # include Maquina::RetainPasswords
13
+ # end
14
+ #
15
+ # == Configuration
16
+ #
17
+ # Password retention behavior is controlled by:
18
+ # - +Maquina.configuration.password_retain_count+:: Number of previous passwords to retain
19
+ #
20
+ # == Callbacks
21
+ #
22
+ # When included, automatically adds:
23
+ # - Validation to prevent password reuse
24
+ # - After create: Stores initial password in history
25
+ # - After update: Stores new password in history when password changes
26
+ #
27
+ # == Validations
28
+ #
29
+ # - +password+:: Must not match any previously used passwords within retention limit
30
+ #
31
+ # == Example
32
+ #
33
+ # class User < ApplicationRecord
34
+ # include Maquina::RetainPasswords
35
+ # has_secure_password
36
+ # end
37
+ #
38
+ # user.update(password: 'old_password') # Stored in history
39
+ # user.update(password: 'old_password') # Validation error: password already used
40
+ #
4
41
  module RetainPasswords
5
42
  extend ActiveSupport::Concern
6
43
 
@@ -11,6 +48,10 @@ module Maquina
11
48
 
12
49
  private
13
50
 
51
+ # Validates that the new password hasn't been used before
52
+ #
53
+ # Compares the new password against stored password history
54
+ # Adds error if password was previously used within retention limit
14
55
  def password_uniqueness
15
56
  return if Maquina.configuration.password_retain_count.blank? || Maquina.configuration.password_retain_count.zero?
16
57
 
@@ -24,6 +65,9 @@ module Maquina
24
65
  errors.add(:password, :password_already_used) if used_before.present?
25
66
  end
26
67
 
68
+ # Stores the current password digest in password history
69
+ #
70
+ # Delegates to UsedPassword to handle storage and history maintenance
27
71
  def store_password_digest
28
72
  Maquina::UsedPassword.store_password_digest(id, password_digest)
29
73
  end
@@ -0,0 +1,92 @@
1
+ module Maquina
2
+ module SqliteSearch
3
+ extend ActiveSupport::Concern
4
+
5
+ private def update_search_index
6
+ primary_key = self.class.primary_key
7
+ table_name = self.class.table_name
8
+ foreign_key = self.class.to_s.foreign_key
9
+
10
+ search_attrs = @@search_scope_attrs.each_with_object({}) { |attr, acc|
11
+ acc[attr] = quote_string(send(attr) || "")
12
+ }
13
+ id_value = attributes[primary_key]
14
+
15
+ sql_delete = <<~SQL.strip
16
+ DELETE FROM fts_#{table_name} WHERE #{foreign_key} = #{id_value};
17
+ SQL
18
+ self.class.connection.execute(sql_delete)
19
+
20
+ sql_insert = <<~SQL.strip
21
+ INSERT INTO fts_#{table_name}(#{search_attrs.keys.join(", ")}, #{foreign_key})
22
+ VALUES (#{search_attrs.values.map { |value| "'#{value}'" }.join(", ")}, #{attributes[primary_key]});
23
+ SQL
24
+ self.class.connection.execute(sql_insert)
25
+ end
26
+
27
+ private def delete_search_index
28
+ primary_key = self.class.primary_key
29
+ table_name = self.class.table_name
30
+ foreign_key = self.class.to_s.foreign_key
31
+ id_value = attributes[primary_key]
32
+
33
+ sql_delete = <<~SQL.strip
34
+ DELETE FROM fts_#{table_name} WHERE #{foreign_key} = #{id_value};
35
+ SQL
36
+ self.class.connection.execute(sql_delete)
37
+ end
38
+
39
+ included do
40
+ after_save_commit :update_search_index
41
+ after_destroy_commit :delete_search_index
42
+
43
+ scope_foreign_key = to_s.foreign_key
44
+ scope :full_search, ->(query) {
45
+ return none if query.blank?
46
+
47
+ sql = <<~SQL.strip
48
+ SELECT #{scope_foreign_key} AS id FROM fts_#{table_name}
49
+ WHERE fts_#{table_name} = '#{query}' ORDER BY rank;
50
+ SQL
51
+ ids = connection.execute(sql).map(&:values).flatten
52
+ where(id: ids)
53
+ }
54
+ end
55
+
56
+ class_methods do
57
+ def search_scope(*attrs)
58
+ @@search_scope_attrs = attrs
59
+ end
60
+
61
+ def rebuild_search_index(*ids)
62
+ target_ids = Array(ids)
63
+ target_ids = self.ids if target_ids.empty?
64
+
65
+ scope_foreign_key = to_s.foreign_key
66
+
67
+ delete_where = Array(ids).any? ? "WHERE #{scope_foreign_key} IN (#{ids.join(", ")})" : ""
68
+ sql_delete = <<~SQL.strip
69
+ DELETE FROM fts_#{table_name} #{delete_where};
70
+ SQL
71
+ connection.execute(sql_delete)
72
+
73
+ target_ids.each do |id|
74
+ record = where(id: id).pluck(*@@search_scope_attrs, :id).first
75
+ if record.present?
76
+ id = record.pop
77
+
78
+ sql_insert = <<~SQL.strip
79
+ INSERT INTO fts_#{table_name}(#{@@search_scope_attrs.join(", ")}, #{scope_foreign_key})
80
+ VALUES (#{record.map { |value| "'#{quote_string(value)}'" }.join(", ")}, #{id});
81
+ SQL
82
+ connection.execute(sql_insert)
83
+ end
84
+ end
85
+ end
86
+
87
+ def quote_string(s)
88
+ s.gsub("\\", '\&\&').gsub("'", "''")
89
+ end
90
+ end
91
+ end
92
+ end
@@ -1,6 +1,32 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Maquina
4
+ ##
5
+ # A concern that adds user association to models.
6
+ #
7
+ # == Usage
8
+ #
9
+ # Include this concern in any model that needs to be scoped to a user:
10
+ #
11
+ # class MyModel < ApplicationRecord
12
+ # include Maquina::UserScoped
13
+ # end
14
+ #
15
+ # == Associations
16
+ #
17
+ # When included, automatically adds:
18
+ # - +user+:: Belongs to a User through maquina_user_id foreign key
19
+ #
20
+ # == Example
21
+ #
22
+ # class Document < ApplicationRecord
23
+ # include Maquina::UserScoped
24
+ # # Document now has user association
25
+ # end
26
+ #
27
+ # document = Document.create(user: current_user)
28
+ # document.user # => returns associated Maquina::User
29
+ #
4
30
  module UserScoped
5
31
  extend ActiveSupport::Concern
6
32