maquina 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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>