maquina 0.5.2 → 0.7.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.ruby-version +1 -1
- data/Gemfile +2 -0
- data/Gemfile.lock +3 -35
- data/app/assets/javascripts/controllers/alert_controller.js +22 -0
- data/app/assets/stylesheets/maquina/application.tailwind.css +4 -101
- data/app/assets/stylesheets/maquina.css +114 -0
- data/app/controllers/concerns/maquina/authenticate.rb +29 -1
- data/app/controllers/concerns/maquina/index.rb +81 -2
- data/app/controllers/concerns/maquina/resourceful.rb +95 -9
- data/app/controllers/maquina/application_controller.rb +1 -1
- data/app/controllers/maquina/dashboard_controller.rb +10 -5
- data/app/controllers/maquina/invitations_controller.rb +0 -2
- data/app/controllers/maquina/plans_controller.rb +13 -23
- data/app/controllers/maquina/sessions_controller.rb +1 -1
- data/app/controllers/maquina/users_controller.rb +0 -2
- data/app/helpers/maquina/application_helper.rb +0 -8
- data/app/helpers/maquina/navbar_menu_helper.rb +1 -1
- data/app/models/concerns/maquina/blockeable.rb +64 -0
- data/app/models/concerns/maquina/organization_scoped.rb +26 -0
- data/app/models/concerns/maquina/retain_passwords.rb +44 -0
- data/app/models/concerns/maquina/sqlite_search.rb +92 -0
- data/app/models/concerns/maquina/user_scoped.rb +26 -0
- data/app/models/maquina/active_session.rb +40 -0
- data/app/models/maquina/current.rb +39 -2
- data/app/models/maquina/invitation.rb +28 -0
- data/app/models/maquina/membership.rb +23 -1
- data/app/models/maquina/organization.rb +19 -2
- data/app/models/maquina/plan.rb +26 -6
- data/app/models/maquina/used_password.rb +30 -0
- data/app/models/maquina/user.rb +50 -8
- data/app/policies/maquina/application_policy.rb +1 -1
- data/app/policies/maquina/dashboard_policy.rb +7 -0
- data/app/views/layouts/maquina/application.html.erb +2 -2
- data/app/views/layouts/maquina/sessions.html.erb +1 -1
- data/app/views/maquina/application/_navbar.html.erb +8 -3
- data/app/views/maquina/application/components/checkbox_component.rb +3 -3
- data/app/views/maquina/application/components/component_base.rb +3 -2
- data/app/views/maquina/application/edit.html.erb +10 -7
- data/app/views/maquina/application/filters.rb +118 -0
- data/app/views/maquina/application/index.html.erb +6 -3
- data/app/views/maquina/application/index_header.rb +5 -2
- data/app/views/maquina/application/index_table.rb +71 -6
- data/app/views/maquina/application/index_tools.rb +17 -0
- data/app/views/maquina/application/new.html.erb +10 -7
- data/app/views/maquina/application/search.rb +42 -0
- data/app/views/maquina/application/sessions_header.rb +3 -9
- data/app/views/maquina/dashboard/index.html.erb +4 -0
- data/app/views/maquina/dashboard/stats.rb +35 -0
- data/app/views/maquina/dashboard/tasks.rb +124 -0
- data/app/views/maquina/first_runs/form.rb +0 -2
- data/app/views/maquina/first_runs/show.html.erb +4 -1
- data/app/views/maquina/navbar/title.rb +4 -2
- data/config/importmap.rb +1 -13
- data/config/locales/flash.en.yml +6 -0
- data/config/locales/flash.es.yml +6 -0
- data/config/locales/forms.en.yml +33 -4
- data/config/locales/forms.es.yml +22 -11
- data/config/locales/models.en.yml +10 -0
- data/config/locales/models.es.yml +10 -0
- data/config/locales/views.en.yml +33 -5
- data/config/locales/views.es.yml +28 -10
- data/config/routes.rb +1 -0
- data/db/migrate/20221109010726_create_maquina_plans.rb +1 -1
- data/db/migrate/20221113000409_create_maquina_users.rb +1 -1
- data/db/migrate/20221113020108_create_maquina_used_passwords.rb +1 -1
- data/db/migrate/20221115223414_create_maquina_active_sessions.rb +1 -3
- data/db/migrate/20230201203922_create_maquina_invitations.rb +1 -1
- data/db/migrate/20230829183530_create_maquina_organizations.rb +1 -1
- data/db/migrate/20230829192656_create_maquina_memberships.rb +2 -4
- data/db/migrate/20241109191405_add_counter_cache_to_plans.rb +5 -0
- data/lib/generators/maquina/install_generator.rb +67 -1
- data/lib/generators/maquina/tailwind_config/templates/lib/generators/tailwind_config/templates/config/tailwind.config.js.tt +9 -5
- data/lib/generators/maquina/tailwind_config/templates/lib/tasks/tailwind.rake.tt +2 -0
- data/lib/generators/maquina/templates/config/initializers/maquina.rb.tt +2 -1
- data/lib/maquina/engine.rb +6 -3
- data/lib/maquina/version.rb +1 -1
- data/lib/maquina.rb +2 -9
- metadata +20 -76
- data/app/assets/javascripts/maquina/application.js +0 -4
- data/app/assets/javascripts/maquina/controllers/alert_controller.js +0 -29
- data/app/assets/javascripts/maquina/controllers/application.js +0 -9
- data/app/assets/javascripts/maquina/controllers/index.js +0 -11
- data/app/models/concerns/maquina/authenticate_by.rb +0 -33
- data/app/views/maquina/navbar/search.rb +0 -40
- data/lib/generators/maquina/install_templates/install_templates_generator.rb +0 -31
- /data/app/assets/javascripts/{maquina/controllers → controllers}/backdrop_controller.js +0 -0
- /data/app/assets/javascripts/{maquina/controllers → controllers}/file_controller.js +0 -0
- /data/app/assets/javascripts/{maquina/controllers → controllers}/mobile_menu_controller.js +0 -0
- /data/app/assets/javascripts/{maquina/controllers → controllers}/modal_controller.js +0 -0
- /data/app/assets/javascripts/{maquina/controllers → controllers}/modal_open_controller.js +0 -0
- /data/app/assets/javascripts/{maquina/controllers → controllers}/popup_menu_controller.js +0 -0
- /data/app/assets/javascripts/{maquina/controllers → controllers}/submit_form_controller.js +0 -0
@@ -1,6 +1,35 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module Maquina
|
4
|
+
##
|
5
|
+
# A class representing the ActiveSession model in the Maquina module.
|
6
|
+
# Manages active user sessions and their expiration.
|
7
|
+
#
|
8
|
+
# == Attributes
|
9
|
+
#
|
10
|
+
# - +maquina_user_id+:: Foreign key reference to the associated User.
|
11
|
+
# - +expires_at+:: Timestamp when the session expires.
|
12
|
+
# - +user_agent+:: The user agent string from the client browser.
|
13
|
+
# - +remote_addr+:: The remote IP address of the client.
|
14
|
+
# - +return_url+:: URL to return to after session activities.
|
15
|
+
#
|
16
|
+
# == Associations
|
17
|
+
#
|
18
|
+
# - +user+:: Belongs to a User.
|
19
|
+
#
|
20
|
+
# == Validations
|
21
|
+
#
|
22
|
+
# - +expires_at+:: Must be present and in the future if session expiration is configured.
|
23
|
+
# - +user+:: Must not be blocked.
|
24
|
+
#
|
25
|
+
# == Callbacks
|
26
|
+
#
|
27
|
+
# - After initialize: Sets initial expiration time for new sessions based on configuration.
|
28
|
+
#
|
29
|
+
# == Delegations
|
30
|
+
#
|
31
|
+
# - +blocked?+:: Delegated to the associated user.
|
32
|
+
#
|
4
33
|
class ActiveSession < ApplicationRecord
|
5
34
|
belongs_to :user, class_name: "Maquina::User", foreign_key: :maquina_user_id
|
6
35
|
delegate :blocked?, to: :user
|
@@ -10,6 +39,11 @@ module Maquina
|
|
10
39
|
validates :expires_at, presence: true, comparison: {greater_than: Time.zone.now}, if: ->(session) { (session.new_record? || session.changed.includes?("expires_at")) && Maquina.configuration.session_expiration.present? }
|
11
40
|
validate :non_blocked_user
|
12
41
|
|
42
|
+
# Checks if the session has expired
|
43
|
+
#
|
44
|
+
# Returns:
|
45
|
+
# - true -> if expires_at is set and in the past
|
46
|
+
# - false -> if expires_at is not set or is in the future
|
13
47
|
def expired?
|
14
48
|
return false if expires_at.blank?
|
15
49
|
|
@@ -18,12 +52,18 @@ module Maquina
|
|
18
52
|
|
19
53
|
private
|
20
54
|
|
55
|
+
# Validates that the associated user is not blocked
|
56
|
+
#
|
57
|
+
# Adds an error if the user is blocked
|
21
58
|
def non_blocked_user
|
22
59
|
return if user.blank? || !blocked?
|
23
60
|
|
24
61
|
errors.add(:user, :blocked)
|
25
62
|
end
|
26
63
|
|
64
|
+
# Sets the initial expiration time for new sessions
|
65
|
+
#
|
66
|
+
# Uses the configured session expiration duration if available
|
27
67
|
def configure_expiration
|
28
68
|
if new_record? && expires_at.blank? && Maquina.configuration.session_expiration.present?
|
29
69
|
self.expires_at = Time.zone.now.since(Maquina.configuration.session_expiration)
|
@@ -1,20 +1,57 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module Maquina
|
4
|
+
##
|
5
|
+
# A class representing the Current context in the Maquina module.
|
6
|
+
# Manages per-request global state for the current user session.
|
7
|
+
#
|
8
|
+
# == Attributes
|
9
|
+
#
|
10
|
+
# - +active_session+:: The current active session for the request.
|
11
|
+
# - +user+:: The current authenticated user.
|
12
|
+
# - +membership+:: The current user's default membership.
|
13
|
+
#
|
14
|
+
# == Usage
|
15
|
+
#
|
16
|
+
# Current attributes are cleared between requests and provide a thread-safe way to
|
17
|
+
# handle request-specific data. They are automatically reset after each request.
|
18
|
+
#
|
4
19
|
class Current < ActiveSupport::CurrentAttributes
|
5
20
|
attribute :active_session, :user, :membership
|
6
21
|
|
22
|
+
# Checks if there is a valid signed-in session
|
23
|
+
#
|
24
|
+
# Returns:
|
25
|
+
# - true -> if there is an active, non-expired, non-blocked session
|
26
|
+
# - false -> if there is no active session or the session is invalid
|
7
27
|
def signed_in?
|
8
|
-
|
9
|
-
|
28
|
+
if active_session.blank? || active_session.expired? || active_session.blocked?
|
29
|
+
self.user = nil
|
30
|
+
self.membership = nil
|
31
|
+
|
32
|
+
false
|
33
|
+
end
|
34
|
+
true
|
10
35
|
end
|
11
36
|
|
37
|
+
# Sets the active session and updates related attributes
|
38
|
+
#
|
39
|
+
# When setting a new active session, automatically updates the current user
|
40
|
+
# and their default membership.
|
41
|
+
#
|
42
|
+
# Args:
|
43
|
+
# - value -> Maquina::ActiveSession: The active session to set
|
12
44
|
def active_session=(value)
|
13
45
|
super
|
14
46
|
self.user = value&.user
|
15
47
|
self.membership = user.default_membership
|
16
48
|
end
|
17
49
|
|
50
|
+
# Checks if the current user has management privileges
|
51
|
+
#
|
52
|
+
# Returns:
|
53
|
+
# - true -> if the current user has management access
|
54
|
+
# - false -> if the current user does not have management access
|
18
55
|
def management?
|
19
56
|
user.management?
|
20
57
|
end
|
@@ -1,13 +1,41 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module Maquina
|
4
|
+
##
|
5
|
+
# A class representing the Invitation model in the Maquina module.
|
6
|
+
# Manages invitations sent to users to join the system.
|
7
|
+
#
|
8
|
+
# == Attributes
|
9
|
+
#
|
10
|
+
# - +email+:: The email address of the invited user (required, must be valid format).
|
11
|
+
# - +accepted_at+:: Timestamp indicating when the invitation was accepted, nil if pending.
|
12
|
+
#
|
13
|
+
# == Validations
|
14
|
+
#
|
15
|
+
# - +email+:: Must be present and in valid email format.
|
16
|
+
#
|
4
17
|
class Invitation < ApplicationRecord
|
5
18
|
validates :email, presence: true, format: {with: URI::MailTo::EMAIL_REGEXP}
|
6
19
|
|
20
|
+
# Checks if the invitation has been accepted
|
21
|
+
#
|
22
|
+
# Returns:
|
23
|
+
# - true -> if the invitation has been accepted
|
24
|
+
# - false -> if the invitation is still pending
|
7
25
|
def accepted?
|
8
26
|
accepted_at.present?
|
9
27
|
end
|
10
28
|
|
29
|
+
# Marks the invitation as accepted with the current timestamp
|
30
|
+
#
|
31
|
+
# Updates the accepted_at timestamp with the current time.
|
32
|
+
#
|
33
|
+
# Returns:
|
34
|
+
# - true -> if the update was successful
|
35
|
+
# - false -> if the update failed
|
36
|
+
#
|
37
|
+
# Raises:
|
38
|
+
# - ActiveRecord::RecordInvalid -> if validation fails
|
11
39
|
def accept!
|
12
40
|
update!(accepted_at: Time.zone.now)
|
13
41
|
end
|
@@ -1,10 +1,32 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module Maquina
|
4
|
+
##
|
5
|
+
# A class representing the Membership model in the Maquina module.
|
6
|
+
# Links users to organizations and defines their roles and permissions within them.
|
7
|
+
#
|
8
|
+
# == Attributes
|
9
|
+
#
|
10
|
+
# - +maquina_organization_id+:: Foreign key reference to the associated Organization.
|
11
|
+
# - +maquina_user_id+:: Foreign key reference to the associated User.
|
12
|
+
# - +owner+:: Boolean flag indicating if the member is an owner of the organization.
|
13
|
+
# - +role+:: Enum defining the member's role within the organization (configured via Maquina.configuration.membership_roles).
|
14
|
+
# - +blocked_at+:: Timestamp indicating when the membership was blocked, nil if active.
|
15
|
+
#
|
16
|
+
# == Associations
|
17
|
+
#
|
18
|
+
# - +organization+:: Belongs to an Organization.
|
19
|
+
# - +user+:: Belongs to a User.
|
20
|
+
#
|
21
|
+
# == Enums
|
22
|
+
#
|
23
|
+
# - +role+:: Available roles are defined in Maquina.configuration.membership_roles.
|
24
|
+
# These define the member's permissions and access level within their organization.
|
25
|
+
#
|
4
26
|
class Membership < ApplicationRecord
|
5
27
|
belongs_to :organization, class_name: "Maquina::Organization", foreign_key: :maquina_organization_id
|
6
28
|
belongs_to :user, class_name: "Maquina::User", foreign_key: :maquina_user_id
|
7
29
|
|
8
|
-
enum role
|
30
|
+
enum :role, Maquina.configuration.membership_roles
|
9
31
|
end
|
10
32
|
end
|
@@ -1,8 +1,25 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module Maquina
|
4
|
+
##
|
5
|
+
# A class representing the Organization model in the Maquina module.
|
6
|
+
#
|
7
|
+
# == Attributes
|
8
|
+
#
|
9
|
+
# - +name+:: The name of the Organization.
|
10
|
+
# - +active+:: A boolean flag indicating whether the Organization is active.
|
11
|
+
# - +maquina_plan_id+:: The foreign key reference to the associated Plan.
|
12
|
+
#
|
13
|
+
# == Associations
|
14
|
+
#
|
15
|
+
# - +plan+:: Belongs to a Plan (optional). When the associated Plan is deleted, the reference is set to null.
|
16
|
+
#
|
17
|
+
# == Usage
|
18
|
+
#
|
19
|
+
# The Organization model represents an organizational entity within the Maquina module.
|
20
|
+
# Organizations can be associated with a pricing plan and their active status can be tracked.
|
21
|
+
#
|
4
22
|
class Organization < ApplicationRecord
|
5
|
-
|
6
|
-
belongs_to :plan, class_name: "Maquina::Plan", foreign_key: :maquina_plan_id, optional: true
|
23
|
+
belongs_to :plan, class_name: "Maquina::Plan", foreign_key: :maquina_plan_id, optional: true, counter_cache: true
|
7
24
|
end
|
8
25
|
end
|
data/app/models/maquina/plan.rb
CHANGED
@@ -8,6 +8,11 @@ module Maquina
|
|
8
8
|
#
|
9
9
|
# - +price+:: The price of the Plan, represented as a monetary value with currency.
|
10
10
|
# - +name+:: The name of the Plan.
|
11
|
+
# - +organizations_count+:: Counter cache for the number of associated organizations.
|
12
|
+
#
|
13
|
+
# == Associations
|
14
|
+
#
|
15
|
+
# - +organizations+:: Has many organizations. When plan is deleted, organization references are set to null.
|
11
16
|
#
|
12
17
|
# == Validations
|
13
18
|
#
|
@@ -15,22 +20,37 @@ module Maquina
|
|
15
20
|
# - +price+:: Must be greater than 0 if the Plan is not marked as free.
|
16
21
|
# - +name+:: Must be present and unique.
|
17
22
|
#
|
18
|
-
# ==
|
23
|
+
# == Monetized Attributes
|
19
24
|
#
|
20
|
-
# - +
|
25
|
+
# - +price+:: Uses the Money gem to handle currency and monetary calculations.
|
21
26
|
#
|
22
27
|
# == Usage
|
23
28
|
#
|
24
29
|
# The Plan model represents a pricing plan in the Maquina module. To use the Plan model, create instances with valid
|
25
30
|
# attributes and use the provided methods and scopes for querying and manipulating plan data.
|
26
|
-
|
31
|
+
#
|
27
32
|
class Plan < ApplicationRecord
|
28
|
-
has_many :organizations, class_name: "Maquina::Organization", foreign_key: :maquina_plan_id, dependent: :nullify
|
33
|
+
has_many :organizations, class_name: "Maquina::Organization", foreign_key: :maquina_plan_id, dependent: :nullify,
|
34
|
+
counter_cache: true
|
29
35
|
|
30
36
|
monetize :price_cents
|
31
37
|
|
32
|
-
validates :price, numericality: {greater_than_or_equal_to: 0}, if: ->(plan) { plan.free? }
|
33
|
-
validates :price, numericality: {greater_than: 0}, if: ->(plan) { !plan.free? }
|
38
|
+
validates :price, numericality: {greater_than_or_equal_to: 0.00}, if: ->(plan) { plan.free? }
|
39
|
+
validates :price, numericality: {greater_than: 0.00}, if: ->(plan) { !plan.free? }
|
34
40
|
validates :name, presence: true, uniqueness: true
|
41
|
+
|
42
|
+
def self.searchable?
|
43
|
+
true
|
44
|
+
end
|
45
|
+
|
46
|
+
if Maquina.configuration.search_adapter == :sqlite
|
47
|
+
include Maquina::SqliteSearch
|
48
|
+
|
49
|
+
search_scope :name
|
50
|
+
elsif Maquina.configuration.search_adapter == :pg_search
|
51
|
+
include PgSearch::Model
|
52
|
+
|
53
|
+
pg_search_scope :full_search, against: [:name], using: {tsearch: {prefix: true}}
|
54
|
+
end
|
35
55
|
end
|
36
56
|
end
|
@@ -1,12 +1,42 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module Maquina
|
4
|
+
##
|
5
|
+
# A class representing the UsedPassword model in the Maquina module.
|
6
|
+
# Keeps track of previously used passwords for users to prevent password reuse.
|
7
|
+
#
|
8
|
+
# == Attributes
|
9
|
+
#
|
10
|
+
# - +password_digest+:: Encrypted password digest (required).
|
11
|
+
# - +maquina_user_id+:: Foreign key reference to the associated User.
|
12
|
+
#
|
13
|
+
# == Associations
|
14
|
+
#
|
15
|
+
# - +user+:: Belongs to a User.
|
16
|
+
#
|
17
|
+
# == Encryption
|
18
|
+
#
|
19
|
+
# - +password_digest+:: Value is encrypted at rest in the database.
|
20
|
+
#
|
4
21
|
class UsedPassword < ApplicationRecord
|
5
22
|
belongs_to :user, class_name: "Maquina::User", foreign_key: :maquina_user_id
|
6
23
|
|
7
24
|
encrypts :password_digest
|
8
25
|
validates :password_digest, presence: true
|
9
26
|
|
27
|
+
# Stores a new password digest for a user while maintaining password history limit.
|
28
|
+
# If password retention is disabled via configuration, no storage occurs.
|
29
|
+
#
|
30
|
+
# Args:
|
31
|
+
# - +user_id+:: Integer representing the ID of the user
|
32
|
+
# - +password_digest+:: String of encrypted password digest to store
|
33
|
+
#
|
34
|
+
# Returns:
|
35
|
+
# - +nil+:: If password retention is disabled (count is nil or 0)
|
36
|
+
# - +Maquina::UsedPassword+:: The newly created used password record
|
37
|
+
#
|
38
|
+
# Example:
|
39
|
+
# Maquina::UsedPassword.store_password_digest(1, "digest123")
|
10
40
|
def self.store_password_digest(user_id, password_digest)
|
11
41
|
return if Maquina.configuration.password_retain_count.blank? || Maquina.configuration.password_retain_count.zero?
|
12
42
|
|
data/app/models/maquina/user.rb
CHANGED
@@ -1,9 +1,46 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module Maquina
|
4
|
+
##
|
5
|
+
# A class representing the User model in the Maquina module.
|
6
|
+
#
|
7
|
+
# == Attributes
|
8
|
+
#
|
9
|
+
# - +email+:: The user's email address (required, unique, automatically normalized).
|
10
|
+
# - +password+:: Encrypted password that must meet complexity requirements.
|
11
|
+
# - +password_expires_at+:: Timestamp indicating when the password expires.
|
12
|
+
#
|
13
|
+
# == Associations
|
14
|
+
#
|
15
|
+
# - +memberships+:: Has many memberships linking users to organizations.
|
16
|
+
# - +active_sessions+:: Has many active sessions that are destroyed when the user is deleted.
|
17
|
+
#
|
18
|
+
# == Included Modules
|
19
|
+
#
|
20
|
+
# - +RetainPasswords+:: Handles password retention functionality.
|
21
|
+
# - +Blockeable+:: Provides blocking/unblocking capabilities.
|
22
|
+
# - +Multifactor+:: Implements multi-factor authentication features.
|
23
|
+
#
|
24
|
+
# == Validations
|
25
|
+
#
|
26
|
+
# - +email+:: Must be present, unique, and in valid email format.
|
27
|
+
# - +password+:: Must contain at least:
|
28
|
+
# * 8 characters
|
29
|
+
# * 1 uppercase letter
|
30
|
+
# * 1 lowercase letter
|
31
|
+
# * 1 number
|
32
|
+
# * 1 special character (@$!%*?&#-=+)
|
33
|
+
#
|
34
|
+
# == Normalizations
|
35
|
+
#
|
36
|
+
# - +email+:: Automatically stripped of whitespace and converted to lowercase.
|
37
|
+
#
|
38
|
+
# == Security
|
39
|
+
#
|
40
|
+
# Uses has_secure_password for password encryption and authentication.
|
41
|
+
#
|
4
42
|
class User < ApplicationRecord
|
5
43
|
include Maquina::RetainPasswords
|
6
|
-
include Maquina::AuthenticateBy
|
7
44
|
include Maquina::Blockeable
|
8
45
|
include Maquina::Multifactor
|
9
46
|
|
@@ -16,24 +53,29 @@ module Maquina
|
|
16
53
|
validates :email, presence: true, uniqueness: true, format: {with: URI::MailTo::EMAIL_REGEXP}
|
17
54
|
validates :password, format: {with: PASSWORD_COMPLEXITY_REGEX}, unless: ->(user) { user.password.blank? }
|
18
55
|
|
19
|
-
|
56
|
+
normalizes :email, with: ->(email) { email.strip.downcase }
|
20
57
|
|
58
|
+
# Checks if the user's password has expired
|
59
|
+
#
|
60
|
+
# Returns true if password_expires_at is set and in the past, false otherwise
|
21
61
|
def expired_password?
|
22
62
|
return false if password_expires_at.blank?
|
23
63
|
|
24
64
|
password_expires_at < Time.zone.now
|
25
65
|
end
|
26
66
|
|
67
|
+
# Returns the user's default active membership
|
68
|
+
#
|
69
|
+
# A default membership is the first unblocked membership associated with an active organization.
|
70
|
+
# Returns nil if user is management or no valid membership exists.
|
71
|
+
#
|
72
|
+
# Returns:
|
73
|
+
# - Maquina::Membership: the default membership
|
74
|
+
# - nil: if user is management or no valid membership exists
|
27
75
|
def default_membership
|
28
76
|
return nil if management?
|
29
77
|
|
30
78
|
memberships.detect { |membership| membership.blocked_at.blank? && membership.organization.present? && membership.organization.active? }
|
31
79
|
end
|
32
|
-
|
33
|
-
private
|
34
|
-
|
35
|
-
def downcase_email
|
36
|
-
self.email = email.downcase
|
37
|
-
end
|
38
80
|
end
|
39
81
|
end
|
@@ -8,11 +8,11 @@
|
|
8
8
|
|
9
9
|
<%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %>
|
10
10
|
|
11
|
-
<%=
|
11
|
+
<%= javascript_importmap_tags %>
|
12
12
|
</head>
|
13
13
|
|
14
14
|
<body class="h-full">
|
15
|
-
<%= render partial: "navbar" %>
|
15
|
+
<%= render partial: "maquina/application/navbar" %>
|
16
16
|
|
17
17
|
<%= turbo_frame_tag :alert do %>
|
18
18
|
<%= render Maquina::Application::Alert.new(flash) %>
|
@@ -1,4 +1,11 @@
|
|
1
|
-
<nav
|
1
|
+
<nav
|
2
|
+
class="
|
3
|
+
transition-all ease-in-out delay-300 backdrop-blur-sm bg-white shadow-sm fixed
|
4
|
+
top-0 left-0 right-0 z-20
|
5
|
+
"
|
6
|
+
data-controller="mobile-menu"
|
7
|
+
data-navbar-target="navbar"
|
8
|
+
>
|
2
9
|
<div class="max-w-7xl mx-auto px-2 sm:px-4 lg:px-8">
|
3
10
|
<div class="flex justify-between h-16">
|
4
11
|
<div class="flex px-2 lg:px-0">
|
@@ -6,8 +13,6 @@
|
|
6
13
|
<%= render Maquina::Navbar::Menu.new %>
|
7
14
|
</div>
|
8
15
|
|
9
|
-
<%= render Maquina::Navbar::Search.new(query: params[:q], url: collection_path) if action_name == "index" && respond_to?(:collection_path) && collection_path.present? && respond_to?(:resource_class) && resource_class.searchable? %>
|
10
|
-
|
11
16
|
<%= render Maquina::Navbar::MobileButton.new %>
|
12
17
|
|
13
18
|
<div class="hidden sm:ml-6 lg:flex sm:items-center">
|
@@ -6,12 +6,12 @@ module Maquina
|
|
6
6
|
class CheckboxComponent < ComponentBase
|
7
7
|
def view_template
|
8
8
|
div(**control_html) do
|
9
|
-
div(class: "flex h-
|
9
|
+
div(class: "flex h-6 items-center") do
|
10
10
|
@form.check_box attribute_name, **input_html
|
11
11
|
end
|
12
|
-
div(class: "text-sm leading-6") do
|
12
|
+
div(class: "ml-3 text-sm leading-6") do
|
13
13
|
@form.label attribute_name, class: "label #{label_css_class}"
|
14
|
-
help_template
|
14
|
+
help_template(margin: false)
|
15
15
|
end
|
16
16
|
end
|
17
17
|
end
|
@@ -5,6 +5,7 @@ module Maquina
|
|
5
5
|
module Components
|
6
6
|
class ComponentBase < Phlex::HTML
|
7
7
|
include ApplicationView
|
8
|
+
include Phlex::Rails::Helpers::TokenList
|
8
9
|
|
9
10
|
def initialize(resource:, form:, options: {})
|
10
11
|
@resource = resource
|
@@ -41,11 +42,11 @@ module Maquina
|
|
41
42
|
@options.dig(:control_html, :label_class) || ""
|
42
43
|
end
|
43
44
|
|
44
|
-
def help_template
|
45
|
+
def help_template(margin: true)
|
45
46
|
help = t("helpers.help.#{@resource.model_name.i18n_key}.#{attribute_name}", default: nil)
|
46
47
|
return if help.blank?
|
47
48
|
|
48
|
-
p(class: "help") { unsafe_raw help }
|
49
|
+
p(class: token_list("help", "mt-0": !margin)) { unsafe_raw help }
|
49
50
|
end
|
50
51
|
|
51
52
|
def error_template
|
@@ -1,9 +1,12 @@
|
|
1
|
-
<div
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
1
|
+
<div
|
2
|
+
class="
|
3
|
+
bg-white mx-auto max-w-4xl py-12 px-4 sm:px-6 lg:px-8 border border-gray-200
|
4
|
+
rounded-lg shadow-sm
|
5
|
+
"
|
6
|
+
>
|
7
|
+
<div class="mx-auto max-w-3xl">
|
8
|
+
<%= turbo_frame_tag class_to_form_frame(resource_class) do %>
|
9
|
+
<%= render Maquina::Application::Edit.new(resource: resource) %>
|
10
|
+
<% end %>
|
8
11
|
</div>
|
9
12
|
</div>
|
@@ -0,0 +1,118 @@
|
|
1
|
+
module Maquina
|
2
|
+
module Application
|
3
|
+
class Filters < Phlex::HTML
|
4
|
+
include ApplicationView
|
5
|
+
include Phlex::Rails::Helpers::LinkTo
|
6
|
+
include Phlex::Rails::Helpers::TokenList
|
7
|
+
|
8
|
+
delegate :request, :filters, :resource_class, :collection_path, :params, to: :helpers
|
9
|
+
|
10
|
+
def view_template
|
11
|
+
div(class: "flex flex-1 items-center space-x-2") do
|
12
|
+
filters.each_pair do |field, options|
|
13
|
+
selected_option = calculate_selected(field, options)
|
14
|
+
display_filter(field, selected_option, options)
|
15
|
+
end
|
16
|
+
|
17
|
+
active_filters = filters.keys.select { |field| params[field].present? }
|
18
|
+
link_to(collection_path(**clear_filters), class: token_list("text-skin-dimmed text-xs border border-gray-200 inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 hover:bg-white hover:text-skin-base py-2 h-9 px-2 lg:px-3", hidden: active_filters.empty?)) do
|
19
|
+
span { t("filters.clear") }
|
20
|
+
svg(width: "15", height: "15", viewbox: "0 0 15 15", fill: "none", xmlns: "http://www.w3.org/2000/svg", class: "ml-2 h-4 w-4") do |s|
|
21
|
+
s.path(d: "M11.7816 4.03157C12.0062 3.80702 12.0062 3.44295 11.7816 3.2184C11.5571 2.99385 11.193 2.99385 10.9685 3.2184L7.50005 6.68682L4.03164 3.2184C3.80708 2.99385 3.44301 2.99385 3.21846 3.2184C2.99391 3.44295 2.99391 3.80702 3.21846 4.03157L6.68688 7.49999L3.21846 10.9684C2.99391 11.193 2.99391 11.557 3.21846 11.7816C3.44301 12.0061 3.80708 12.0061 4.03164 11.7816L7.50005 8.31316L10.9685 11.7816C11.193 12.0061 11.5571 12.0061 11.7816 11.7816C12.0062 11.557 12.0062 11.193 11.7816 10.9684L8.31322 7.49999L11.7816 4.03157Z",
|
22
|
+
fill: "currentColor",
|
23
|
+
fill_rule: "evenodd",
|
24
|
+
clip_rule: "evenodd")
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
def calculate_selected(field, options)
|
33
|
+
param_value = params[field] || nil
|
34
|
+
value = []
|
35
|
+
|
36
|
+
value = options.detect { |option| option[1].to_s == param_value } || [] if param_value.present?
|
37
|
+
|
38
|
+
value
|
39
|
+
end
|
40
|
+
|
41
|
+
def display_filter(field, selected_option, options)
|
42
|
+
div(class: "relative inline-block text-left", data: {controller: "popup-menu"}) do
|
43
|
+
button(class: "text-skin-dimmed inline-flex items-center justify-center whitespace-nowrap font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 border bg-gray-50 shadow-sm hover:bg-white hover:text-skin-base rounded-md px-3 text-xs h-9",
|
44
|
+
type: "button", aria_haspopup: "dialog", aria_expanded: "false", data_action: "popup-menu#toggleTransition") do
|
45
|
+
unsafe_raw filter_icon
|
46
|
+
plain resource_class.human_attribute_name(field)
|
47
|
+
|
48
|
+
if selected_option.any?
|
49
|
+
div(data_orientation: "vertical", role: "none", class: "hiddens shrink-0 border-l border-gray-300 w-[1px] mx-2 h-4")
|
50
|
+
div(class: "hiddens space-x-1 flex") do
|
51
|
+
div(class: "inline-flex items-center border py-0.5 text-xs transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 border-transparent bg-skin-accented/80 text-white hover:bg-skin-accented/90 rounded-sm px-1 font-normal") { selected_option.first }
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
div(class: "hidden absolute left-0 z-10 mt-2 w-56 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none",
|
57
|
+
role: "menu", "aria-orientation": "vertical", "aria-labelledby": "menu-button", tabindex: "-1", data: data_attributes) do
|
58
|
+
div(class: "py-1", role: "none") do
|
59
|
+
options.each_pair do |label, value|
|
60
|
+
current_value = value.to_s == selected_option.last.to_s
|
61
|
+
|
62
|
+
link_to(current_params(field, value), class: "text-skin-muted block px-4 py-2 text-sm relative flex cursor-default select-none items-center hover:bg-gray-100", role: "menuitem", tabindex: "-1", "data-value": value) do
|
63
|
+
div(class: "filter-option", selected: current_value) do
|
64
|
+
unsafe_raw selected_icon
|
65
|
+
end
|
66
|
+
span { label }
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
def current_params(filter, option)
|
75
|
+
current_params = request.query_parameters.except("page")
|
76
|
+
current_params.reject! { |key, value| value.blank? }
|
77
|
+
|
78
|
+
if current_params[filter] == option.to_s
|
79
|
+
current_params.except(filter)
|
80
|
+
else
|
81
|
+
current_params.merge(filter => option)
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
def clear_filters
|
86
|
+
request.query_parameters.except("page", *filters.keys.map(&:to_s))
|
87
|
+
end
|
88
|
+
|
89
|
+
def selected_icon
|
90
|
+
<<~SVG
|
91
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewbox="0 0 15 15" fill="none" class="h-4 w-4">
|
92
|
+
<path d="M11.4669 3.72684C11.7558 3.91574 11.8369 4.30308 11.648 4.59198L7.39799 11.092C7.29783 11.2452 7.13556 11.3467 6.95402 11.3699C6.77247 11.3931 6.58989 11.3355 6.45446 11.2124L3.70446 8.71241C3.44905 8.48022 3.43023 8.08494 3.66242 7.82953C3.89461 7.57412 4.28989 7.55529 4.5453 7.78749L6.75292 9.79441L10.6018 3.90792C10.7907 3.61902 11.178 3.53795 11.4669 3.72684Z" fill="currentColor" fill-rule="evenodd" clip-rule="evenodd"/>
|
93
|
+
</svg>
|
94
|
+
SVG
|
95
|
+
end
|
96
|
+
|
97
|
+
def filter_icon
|
98
|
+
<<~SVG
|
99
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewbox="0 0 15 15" fill="none" class="mr-2 h-4 w-4">
|
100
|
+
<path d="M7.49991 0.876892C3.84222 0.876892 0.877075 3.84204 0.877075 7.49972C0.877075 11.1574 3.84222 14.1226 7.49991 14.1226C11.1576 14.1226 14.1227 11.1574 14.1227 7.49972C14.1227 3.84204 11.1576 0.876892 7.49991 0.876892ZM1.82707 7.49972C1.82707 4.36671 4.36689 1.82689 7.49991 1.82689C10.6329 1.82689 13.1727 4.36671 13.1727 7.49972C13.1727 10.6327 10.6329 13.1726 7.49991 13.1726C4.36689 13.1726 1.82707 10.6327 1.82707 7.49972ZM7.50003 4C7.77617 4 8.00003 4.22386 8.00003 4.5V7H10.5C10.7762 7 11 7.22386 11 7.5C11 7.77614 10.7762 8 10.5 8H8.00003V10.5C8.00003 10.7761 7.77617 11 7.50003 11C7.22389 11 7.00003 10.7761 7.00003 10.5V8H4.50003C4.22389 8 4.00003 7.77614 4.00003 7.5C4.00003 7.22386 4.22389 7 4.50003 7H7.00003V4.5C7.00003 4.22386 7.22389 4 7.50003 4Z" fill="currentColor" fill-rule="evenodd" clip-rule="evenodd"/>
|
101
|
+
</svg>
|
102
|
+
SVG
|
103
|
+
end
|
104
|
+
|
105
|
+
def data_attributes
|
106
|
+
{
|
107
|
+
"popup-menu-target": "menu",
|
108
|
+
"transition-enter": "transition ease-out duration-100",
|
109
|
+
"transition-enter-active": "transform opacity-0 scale-95",
|
110
|
+
"transition-enter-to": "transform opacity-100 scale-100",
|
111
|
+
"transition-leave": "transition ease-in duration-75",
|
112
|
+
"transition-leave-active": "transform opacity-100 scale-100",
|
113
|
+
"transition-leave-to": "transform opacity-0 scale-95"
|
114
|
+
}
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|