maquina 0.1.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 (134) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +28 -0
  4. data/Rakefile +8 -0
  5. data/app/assets/images/maquina/maquina.svg +18 -0
  6. data/app/assets/javascripts/maquina/application.js +4 -0
  7. data/app/assets/javascripts/maquina/controllers/alert_controller.js +29 -0
  8. data/app/assets/javascripts/maquina/controllers/application.js +9 -0
  9. data/app/assets/javascripts/maquina/controllers/file_controller.js +60 -0
  10. data/app/assets/javascripts/maquina/controllers/index.js +11 -0
  11. data/app/assets/javascripts/maquina/controllers/mobile_menu_controller.js +31 -0
  12. data/app/assets/javascripts/maquina/controllers/modal_controller.js +39 -0
  13. data/app/assets/javascripts/maquina/controllers/modal_open_controller.js +15 -0
  14. data/app/assets/javascripts/maquina/controllers/popup_menu_controller.js +17 -0
  15. data/app/assets/javascripts/maquina/controllers/submit_form_controller.js +11 -0
  16. data/app/assets/stylesheets/maquina/application.css +15 -0
  17. data/app/assets/stylesheets/maquina/application.tailwind.css +102 -0
  18. data/app/controllers/concerns/maquina/authenticate.rb +41 -0
  19. data/app/controllers/concerns/maquina/create.rb +27 -0
  20. data/app/controllers/concerns/maquina/destroy.rb +28 -0
  21. data/app/controllers/concerns/maquina/edit.rb +29 -0
  22. data/app/controllers/concerns/maquina/index.rb +33 -0
  23. data/app/controllers/concerns/maquina/new.rb +22 -0
  24. data/app/controllers/concerns/maquina/resourceful.rb +180 -0
  25. data/app/controllers/concerns/maquina/show.rb +27 -0
  26. data/app/controllers/concerns/maquina/update.rb +31 -0
  27. data/app/controllers/maquina/accept_invitations_controller.rb +28 -0
  28. data/app/controllers/maquina/application_controller.rb +19 -0
  29. data/app/controllers/maquina/dashboard_controller.rb +16 -0
  30. data/app/controllers/maquina/invitations_controller.rb +51 -0
  31. data/app/controllers/maquina/plans_controller.rb +56 -0
  32. data/app/controllers/maquina/sessions_controller.rb +56 -0
  33. data/app/controllers/maquina/unauthorized_controller.rb +9 -0
  34. data/app/controllers/maquina/users_controller.rb +24 -0
  35. data/app/helpers/maquina/application_helper.rb +19 -0
  36. data/app/helpers/maquina/navbar_menu_helper.rb +29 -0
  37. data/app/helpers/maquina/views_helper.rb +9 -0
  38. data/app/jobs/maquina/application_job.rb +4 -0
  39. data/app/mailers/maquina/application_mailer.rb +8 -0
  40. data/app/mailers/maquina/user_notifications_mailer.rb +16 -0
  41. data/app/models/concerns/maquina/authenticate_by.rb +33 -0
  42. data/app/models/concerns/maquina/blockeable.rb +28 -0
  43. data/app/models/concerns/maquina/multifactor.rb +80 -0
  44. data/app/models/concerns/maquina/retain_passwords.rb +32 -0
  45. data/app/models/concerns/maquina/searchable.rb +24 -0
  46. data/app/models/maquina/active_session.rb +33 -0
  47. data/app/models/maquina/application_record.rb +11 -0
  48. data/app/models/maquina/current.rb +21 -0
  49. data/app/models/maquina/invitation.rb +15 -0
  50. data/app/models/maquina/plan.rb +38 -0
  51. data/app/models/maquina/used_password.rb +17 -0
  52. data/app/models/maquina/user.rb +33 -0
  53. data/app/policies/maquina/application_policy.rb +50 -0
  54. data/app/policies/maquina/invitation_policy.rb +13 -0
  55. data/app/policies/maquina/navigation_policy.rb +13 -0
  56. data/app/policies/maquina/plan_policy.rb +49 -0
  57. data/app/policies/maquina/user_policy.rb +27 -0
  58. data/app/views/layouts/maquina/application.html.erb +26 -0
  59. data/app/views/layouts/maquina/mailer.html.erb +377 -0
  60. data/app/views/layouts/maquina/mailer.text.erb +12 -0
  61. data/app/views/layouts/maquina/sessions.html.erb +24 -0
  62. data/app/views/maquina/accept_invitations/new.html.erb +9 -0
  63. data/app/views/maquina/accept_invitations/new_view.rb +41 -0
  64. data/app/views/maquina/application/_navbar.html.erb +21 -0
  65. data/app/views/maquina/application/alert.rb +104 -0
  66. data/app/views/maquina/application/components/action_text_component.rb +20 -0
  67. data/app/views/maquina/application/components/checkbox_component.rb +21 -0
  68. data/app/views/maquina/application/components/component_base.rb +60 -0
  69. data/app/views/maquina/application/components/file_component.rb +59 -0
  70. data/app/views/maquina/application/components/input_component.rb +20 -0
  71. data/app/views/maquina/application/components/select_component.rb +44 -0
  72. data/app/views/maquina/application/create.turbo_stream.erb +11 -0
  73. data/app/views/maquina/application/edit.html.erb +9 -0
  74. data/app/views/maquina/application/edit.rb +17 -0
  75. data/app/views/maquina/application/form.rb +77 -0
  76. data/app/views/maquina/application/index.html.erb +10 -0
  77. data/app/views/maquina/application/index_header.rb +46 -0
  78. data/app/views/maquina/application/index_modal.rb +43 -0
  79. data/app/views/maquina/application/index_table.rb +121 -0
  80. data/app/views/maquina/application/new.html.erb +9 -0
  81. data/app/views/maquina/application/new.rb +18 -0
  82. data/app/views/maquina/application/sessions_header.rb +31 -0
  83. data/app/views/maquina/application/show.html.erb +1 -0
  84. data/app/views/maquina/application/update.turbo_stream.erb +11 -0
  85. data/app/views/maquina/application_view.rb +46 -0
  86. data/app/views/maquina/dashboard/index.html.erb +0 -0
  87. data/app/views/maquina/invitations/create.turbo_stream.erb +13 -0
  88. data/app/views/maquina/invitations/new.html.erb +3 -0
  89. data/app/views/maquina/navbar/menu.rb +62 -0
  90. data/app/views/maquina/navbar/menu_item_link.rb +34 -0
  91. data/app/views/maquina/navbar/mobile_button.rb +29 -0
  92. data/app/views/maquina/navbar/mobile_menu.rb +47 -0
  93. data/app/views/maquina/navbar/notification.rb +37 -0
  94. data/app/views/maquina/navbar/profile.rb +16 -0
  95. data/app/views/maquina/navbar/profile_button.rb +24 -0
  96. data/app/views/maquina/navbar/profile_menu.rb +108 -0
  97. data/app/views/maquina/navbar/profile_menu_item_link.rb +41 -0
  98. data/app/views/maquina/navbar/search.rb +40 -0
  99. data/app/views/maquina/navbar/title.rb +22 -0
  100. data/app/views/maquina/sessions/create.turbo_stream.erb +11 -0
  101. data/app/views/maquina/sessions/form.rb +56 -0
  102. data/app/views/maquina/sessions/new.html.erb +9 -0
  103. data/app/views/maquina/unauthorized/401.html.erb +1 -0
  104. data/app/views/maquina/user_notifications_mailer/invitation_email.html.erb +40 -0
  105. data/app/views/maquina/user_notifications_mailer/invitation_email.text.erb +12 -0
  106. data/config/definitions.rb +1 -0
  107. data/config/initializers/importmap.rb +17 -0
  108. data/config/initializers/money.rb +116 -0
  109. data/config/initializers/pagy.rb +235 -0
  110. data/config/locales/flash.en.yml +44 -0
  111. data/config/locales/forms.en.yml +58 -0
  112. data/config/locales/mailers.en.yml +35 -0
  113. data/config/locales/models.en.yml +34 -0
  114. data/config/locales/routes.en.yml +7 -0
  115. data/config/locales/views.en.yml +45 -0
  116. data/config/routes.rb +17 -0
  117. data/db/migrate/20221109010726_create_maquina_plans.rb +13 -0
  118. data/db/migrate/20221113000409_create_maquina_users.rb +19 -0
  119. data/db/migrate/20221113020108_create_maquina_used_passwords.rb +10 -0
  120. data/db/migrate/20221115223414_create_maquina_active_sessions.rb +15 -0
  121. data/db/migrate/20230201203922_create_maquina_invitations.rb +12 -0
  122. data/db/schema.rb +1 -0
  123. data/lib/generators/maquina/install_generator.rb +32 -0
  124. data/lib/generators/maquina/install_templates/install_templates_generator.rb +31 -0
  125. data/lib/generators/maquina/tailwind_config/tailwind_config_generator.rb +11 -0
  126. data/lib/generators/maquina/tailwind_config/templates/app/assets/config/maquina/tailwind.config.js.tt +68 -0
  127. data/lib/generators/maquina/templates/config/initializers/maquina.rb +3 -0
  128. data/lib/maquina/engine.rb +17 -0
  129. data/lib/maquina/version.rb +3 -0
  130. data/lib/maquina.rb +48 -0
  131. data/lib/tasks/install.rake +19 -0
  132. data/lib/tasks/maquina_tasks.rake +4 -0
  133. data/lib/tasks/tailwind.rake +25 -0
  134. metadata +456 -0
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rotp"
4
+
5
+ module Maquina
6
+ module Multifactor
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ if has_attribute?(:otp_secret_key) && has_attribute?(:otp_recovery_codes)
11
+ encrypts :otp_secret_key, :otp_recovery_codes
12
+
13
+ def multifactor?
14
+ otp_secret_key.present?
15
+ end
16
+
17
+ def enable_multifactor!(multifactor_secret_key)
18
+ self.otp_secret_key = multifactor_secret_key
19
+ self.otp_recovery_codes = 10.times.map { SecureRandom.alphanumeric(12) }.join(" ")
20
+
21
+ save!
22
+ end
23
+
24
+ def disable_multifactor!
25
+ self.last_otp_at = nil
26
+ self.otp_secret_key = nil
27
+ self.otp_recovery_codes = nil
28
+
29
+ save!
30
+ end
31
+
32
+ def verify_multifactor_code!(multifactor_code, multifactor_secret_key = nil)
33
+ multifactor_secret_key ||= otp_secret_key
34
+
35
+ options = {drift_behind: 15}
36
+ options[:after] = last_otp_at.to_i if last_otp_at.present?
37
+
38
+ last_at = totp_instance(multifactor_secret_key).verify(multifactor_code, **options)
39
+
40
+ if last_at.present?
41
+ self.last_otp_at = Time.at(last_at).utc.to_datetime
42
+ save!
43
+ end
44
+
45
+ last_at
46
+ end
47
+
48
+ def verify_multifactor_recovery_code!(recovery_code)
49
+ return nil if !multifactor?
50
+
51
+ codes = otp_recovery_codes.split(" ")
52
+ valid_code = codes.detect do |code|
53
+ ActiveSupport::SecurityUtils.secure_compare(recovery_code.strip, code)
54
+ end
55
+
56
+ if valid_code.present?
57
+ codes.delete(recovery_code.strip)
58
+ self.otp_recovery_codes = codes.join(" ")
59
+ save!
60
+ end
61
+
62
+ valid_code
63
+ end
64
+
65
+ private
66
+
67
+ def totp_instance(multifactor_secret_key = nil)
68
+ multifactor_secret_key ||= otp_secret_key
69
+ @totp_instance ||= ROTP::TOTP.new(multifactor_secret_key, issuer: I18n.t(:application_name), default: "Maquina")
70
+ end
71
+ end
72
+ end
73
+
74
+ class_methods do
75
+ def generate_multifactor_secret
76
+ ROTP::Base32.random
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maquina
4
+ module RetainPasswords
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ validate :password_uniqueness, if: ->(user) { user.password_digest_changed? }
9
+ after_create :store_password_digest
10
+ after_update :store_password_digest, if: ->(user) { user.previous_changes.has_key?(:password_digest) }
11
+
12
+ private
13
+
14
+ def password_uniqueness
15
+ return if Maquina.configuration.password_retain_count.blank? || Maquina.configuration.password_retain_count.zero?
16
+
17
+ used_before = Maquina::UsedPassword.where(user: self).detect do |used_password|
18
+ bcrypt = ::BCrypt::Password.new(used_password.password_digest)
19
+ hashed_value = ::BCrypt::Engine.hash_secret(password, bcrypt.salt)
20
+
21
+ ActiveSupport::SecurityUtils.secure_compare(hashed_value, used_password.password_digest)
22
+ end
23
+
24
+ errors.add(:password, :password_already_used) if used_before.present?
25
+ end
26
+
27
+ def store_password_digest
28
+ Maquina::UsedPassword.store_password_digest(id, password_digest)
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pg_search"
4
+
5
+ module Maquina
6
+ module Searchable
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ include PgSearch::Model
11
+ end
12
+
13
+ class_methods do
14
+ def search_scope(fields: [], options: {})
15
+ return if fields.empty?
16
+
17
+ default_options = {tsearch: {prefix: true, any_word: true}}
18
+ search_options = options.deep_merge(default_options)
19
+
20
+ pg_search_scope :search_full, against: fields, using: search_options # , ignoring: :accents
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maquina
4
+ class ActiveSession < ApplicationRecord
5
+ belongs_to :user, class_name: "Maquina::User", foreign_key: :maquina_user_id
6
+ delegate :blocked?, to: :user
7
+
8
+ after_initialize :configure_expiration
9
+
10
+ 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
+ validate :non_blocked_user
12
+
13
+ def expired?
14
+ return false if expires_at.blank?
15
+
16
+ !expires_at.future?
17
+ end
18
+
19
+ private
20
+
21
+ def non_blocked_user
22
+ return if user.blank? || !blocked?
23
+
24
+ errors.add(:user, :blocked)
25
+ end
26
+
27
+ def configure_expiration
28
+ if new_record? && expires_at.blank? && Maquina.configuration.session_expiration.present?
29
+ self.expires_at = Time.zone.now.since(Maquina.configuration.session_expiration)
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pg_search"
4
+
5
+ module Maquina
6
+ class ApplicationRecord < ActiveRecord::Base
7
+ self.abstract_class = true
8
+
9
+ include PgSearch::Model
10
+ end
11
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maquina
4
+ class Current < ActiveSupport::CurrentAttributes
5
+ attribute :active_session, :user
6
+
7
+ def signed_in?
8
+ return false if active_session.blank?
9
+ !active_session.expired? && !active_session.blocked?
10
+ end
11
+
12
+ def active_session=(value)
13
+ super
14
+ self.user = value&.user
15
+ end
16
+
17
+ def management?
18
+ user.management?
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maquina
4
+ class Invitation < ApplicationRecord
5
+ validates :email, presence: true, format: {with: URI::MailTo::EMAIL_REGEXP}
6
+
7
+ def accepted?
8
+ accepted_at.present?
9
+ end
10
+
11
+ def accept!
12
+ update!(accepted_at: Time.zone.now)
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maquina
4
+ ##
5
+ # A class representing the Plan model in the Maquina module.
6
+ #
7
+ # == Attributes
8
+ #
9
+ # - +price+:: The price of the Plan, represented as a monetary value with currency.
10
+ # - +name+:: The name of the Plan.
11
+ #
12
+ # == Validations
13
+ #
14
+ # - +price+:: Must be greater than or equal to 0 if the Plan is marked as free.
15
+ # - +price+:: Must be greater than 0 if the Plan is not marked as free.
16
+ # - +name+:: Must be present and unique.
17
+ #
18
+ # == Scopes
19
+ #
20
+ # - +search_full+:: Provides a search scope for Plan model, allowing searching by name with options for prefix matching and matching any word.
21
+ #
22
+ # == Usage
23
+ #
24
+ # The Plan model represents a pricing plan in the Maquina module. To use the Plan model, create instances with valid
25
+ # attributes and use the provided methods and scopes for querying and manipulating plan data.
26
+
27
+ class Plan < ApplicationRecord
28
+ include Searchable
29
+
30
+ monetize :price_cents
31
+
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? }
34
+ validates :name, presence: true, uniqueness: true
35
+
36
+ search_scope(fields: [:name])
37
+ end
38
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maquina
4
+ class UsedPassword < ApplicationRecord
5
+ belongs_to :user, class_name: "Maquina::User", foreign_key: :maquina_user_id
6
+
7
+ encrypts :password_digest
8
+ validates :password_digest, presence: true
9
+
10
+ def self.store_password_digest(user_id, password_digest)
11
+ return if Maquina.configuration.password_retain_count.blank? || Maquina.configuration.password_retain_count.zero?
12
+
13
+ Maquina::UsedPassword.where(user: user_id).order(id: :desc).offset(2).delete_all
14
+ Maquina::UsedPassword.create(maquina_user_id: user_id, password_digest: password_digest)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maquina
4
+ class User < ApplicationRecord
5
+ include Maquina::Searchable
6
+ include Maquina::RetainPasswords
7
+ include Maquina::AuthenticateBy
8
+ include Maquina::Blockeable
9
+ include Maquina::Multifactor
10
+
11
+ PASSWORD_COMPLEXITY_REGEX = /\A(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&#-=+])[A-Za-z\d@$!%*?&#-=+]{8,}\z/
12
+ has_secure_password
13
+
14
+ validates :email, presence: true, uniqueness: true, format: {with: URI::MailTo::EMAIL_REGEXP}
15
+ validates :password, format: {with: PASSWORD_COMPLEXITY_REGEX}, unless: ->(user) { user.password.blank? }
16
+
17
+ before_save :downcase_email
18
+
19
+ search_scope(fields: [:email])
20
+
21
+ def expired_password?
22
+ return false if password_expires_at.blank?
23
+
24
+ password_expires_at < Time.zone.now
25
+ end
26
+
27
+ private
28
+
29
+ def downcase_email
30
+ self.email = email.downcase
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maquina
4
+ class ApplicationPolicy < ActionPolicy::Base
5
+ def index?
6
+ false
7
+ end
8
+
9
+ def show?
10
+ false
11
+ end
12
+
13
+ def new?
14
+ false
15
+ end
16
+
17
+ def create?
18
+ new?
19
+ end
20
+
21
+ def edit?
22
+ false
23
+ end
24
+
25
+ def update?
26
+ edit?
27
+ end
28
+
29
+ def destroy?
30
+ false
31
+ end
32
+
33
+ def new_modal?
34
+ false
35
+ end
36
+
37
+ private
38
+
39
+ def management?
40
+ user.management?
41
+ end
42
+
43
+ # Define shared methods useful for most policies.
44
+ # For example:
45
+ #
46
+ # def owner?
47
+ # record.user_id == user.id
48
+ # end
49
+ end
50
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maquina
4
+ class InvitationPolicy < ApplicationPolicy
5
+ def new?
6
+ management?
7
+ end
8
+
9
+ def create?
10
+ new?
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maquina
4
+ class NavigationPolicy < ApplicationPolicy
5
+ def plans?
6
+ user.management?
7
+ end
8
+
9
+ def users?
10
+ user.management?
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maquina
4
+ ##
5
+ # A class representing the authorization policy for the Plan model.
6
+ #
7
+ # == Methods
8
+ #
9
+ # - +index?+:: Returns true if the user has authorization to view the index page for Plan model.
10
+ # - +new?+:: Returns true if the user has authorization to view the new page for creating a new Plan object.
11
+ # - +create?+:: Returns the same result as new?, as it requires the same authorization to create a new Plan object.
12
+ # - +edit?+:: Returns true if the user has authorization to view the edit page for an existing Plan object.
13
+ # - +relation_scope+:: Provides a relation scope for Plan model, allowing customization of query scopes based on authorization policies.
14
+ #
15
+ # == Private Methods
16
+ #
17
+ # - +management?+:: Helper method to determine if the user is in a management role. This method exists in Maquina::ApplicationPolicy.
18
+ #
19
+ # == Usage
20
+ #
21
+ # To use PlanPolicy, define the relevant authorization methods, such as management? or any other custom authorization
22
+ # methods, in the Maquina module or in a related authorization system. Then, inherit from PlanPolicy in your
23
+ # Plan model's policy class, and use the provided authorization methods, such as index?, new?, create?, and edit?, to
24
+ # define the authorization policies for Plan model actions.
25
+
26
+ class PlanPolicy < ApplicationPolicy
27
+ def index?
28
+ management?
29
+ end
30
+
31
+ def new?
32
+ management?
33
+ end
34
+
35
+ def create?
36
+ new?
37
+ end
38
+
39
+ def edit?
40
+ management?
41
+ end
42
+
43
+ private
44
+
45
+ relation_scope do |scope|
46
+ scope
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maquina
4
+ class UserPolicy < ApplicationPolicy
5
+ def index?
6
+ management?
7
+ end
8
+
9
+ def new?
10
+ management?
11
+ end
12
+
13
+ def create?
14
+ new?
15
+ end
16
+
17
+ def new_modal?
18
+ new?
19
+ end
20
+
21
+ private
22
+
23
+ relation_scope do |scope|
24
+ scope.where(management: management?)
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,26 @@
1
+ <!DOCTYPE html>
2
+ <html class="h-full bg-gray-50">
3
+ <head>
4
+ <title><%= t("application_name") %></title>
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <%= csrf_meta_tags %>
7
+ <%= csp_meta_tag %>
8
+
9
+ <%= stylesheet_link_tag "maquina/tailwind", "inter-font", "data-turbo-track": "reload" %>
10
+ <%= stylesheet_link_tag "maquina/application", "data-turbo-track": "reload", media: "all" %>
11
+
12
+ <%= maquina_importmap_tags %>
13
+ </head>
14
+
15
+ <body class="h-full">
16
+ <%= render partial: "navbar" %>
17
+
18
+ <%= turbo_frame_tag :alert do %>
19
+ <%= render Maquina::Application::Alert.new(flash) %>
20
+ <% end %>
21
+
22
+ <main class="container mx-auto mt-24">
23
+ <%= yield %>
24
+ </main>
25
+ </body>
26
+ </html>