maquina 0.5.2 → 0.7.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (93) 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/views/layouts/maquina/application.html.erb +2 -2
  35. data/app/views/layouts/maquina/sessions.html.erb +1 -1
  36. data/app/views/maquina/application/_navbar.html.erb +8 -3
  37. data/app/views/maquina/application/components/checkbox_component.rb +3 -3
  38. data/app/views/maquina/application/components/component_base.rb +3 -2
  39. data/app/views/maquina/application/edit.html.erb +10 -7
  40. data/app/views/maquina/application/filters.rb +118 -0
  41. data/app/views/maquina/application/index.html.erb +6 -3
  42. data/app/views/maquina/application/index_header.rb +5 -2
  43. data/app/views/maquina/application/index_table.rb +71 -6
  44. data/app/views/maquina/application/index_tools.rb +17 -0
  45. data/app/views/maquina/application/new.html.erb +10 -7
  46. data/app/views/maquina/application/search.rb +42 -0
  47. data/app/views/maquina/application/sessions_header.rb +3 -9
  48. data/app/views/maquina/dashboard/index.html.erb +4 -0
  49. data/app/views/maquina/dashboard/stats.rb +35 -0
  50. data/app/views/maquina/dashboard/tasks.rb +124 -0
  51. data/app/views/maquina/first_runs/form.rb +0 -2
  52. data/app/views/maquina/first_runs/show.html.erb +4 -1
  53. data/app/views/maquina/navbar/title.rb +4 -2
  54. data/config/importmap.rb +1 -13
  55. data/config/locales/flash.en.yml +6 -0
  56. data/config/locales/flash.es.yml +6 -0
  57. data/config/locales/forms.en.yml +33 -4
  58. data/config/locales/forms.es.yml +22 -11
  59. data/config/locales/models.en.yml +10 -0
  60. data/config/locales/models.es.yml +10 -0
  61. data/config/locales/views.en.yml +33 -5
  62. data/config/locales/views.es.yml +28 -10
  63. data/config/routes.rb +1 -0
  64. data/db/migrate/20221109010726_create_maquina_plans.rb +1 -1
  65. data/db/migrate/20221113000409_create_maquina_users.rb +1 -1
  66. data/db/migrate/20221113020108_create_maquina_used_passwords.rb +1 -1
  67. data/db/migrate/20221115223414_create_maquina_active_sessions.rb +1 -3
  68. data/db/migrate/20230201203922_create_maquina_invitations.rb +1 -1
  69. data/db/migrate/20230829183530_create_maquina_organizations.rb +1 -1
  70. data/db/migrate/20230829192656_create_maquina_memberships.rb +2 -4
  71. data/db/migrate/20241109191405_add_counter_cache_to_plans.rb +5 -0
  72. data/lib/generators/maquina/install_generator.rb +67 -1
  73. data/lib/generators/maquina/tailwind_config/templates/lib/generators/tailwind_config/templates/config/tailwind.config.js.tt +9 -5
  74. data/lib/generators/maquina/tailwind_config/templates/lib/tasks/tailwind.rake.tt +2 -0
  75. data/lib/generators/maquina/templates/config/initializers/maquina.rb.tt +2 -1
  76. data/lib/maquina/engine.rb +6 -3
  77. data/lib/maquina/version.rb +1 -1
  78. data/lib/maquina.rb +2 -9
  79. metadata +20 -76
  80. data/app/assets/javascripts/maquina/application.js +0 -4
  81. data/app/assets/javascripts/maquina/controllers/alert_controller.js +0 -29
  82. data/app/assets/javascripts/maquina/controllers/application.js +0 -9
  83. data/app/assets/javascripts/maquina/controllers/index.js +0 -11
  84. data/app/models/concerns/maquina/authenticate_by.rb +0 -33
  85. data/app/views/maquina/navbar/search.rb +0 -40
  86. data/lib/generators/maquina/install_templates/install_templates_generator.rb +0 -31
  87. /data/app/assets/javascripts/{maquina/controllers → controllers}/backdrop_controller.js +0 -0
  88. /data/app/assets/javascripts/{maquina/controllers → controllers}/file_controller.js +0 -0
  89. /data/app/assets/javascripts/{maquina/controllers → controllers}/mobile_menu_controller.js +0 -0
  90. /data/app/assets/javascripts/{maquina/controllers → controllers}/modal_controller.js +0 -0
  91. /data/app/assets/javascripts/{maquina/controllers → controllers}/modal_open_controller.js +0 -0
  92. /data/app/assets/javascripts/{maquina/controllers → controllers}/popup_menu_controller.js +0 -0
  93. /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