moduler 0.2.0 → 0.3.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 (101) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/moduler/tailwind.css +1358 -1
  3. data/app/assets/javascripts/moduler/application.js +1 -0
  4. data/app/assets/javascripts/moduler/controllers/flash_controller.js +19 -0
  5. data/app/assets/stylesheets/moduler/tailwind.css +3 -0
  6. data/app/components/moduler/administration/page_component.html.erb +7 -0
  7. data/app/components/moduler/administration/page_component.rb +6 -0
  8. data/app/components/moduler/badge_component.html.erb +8 -0
  9. data/app/components/moduler/badge_component.rb +10 -0
  10. data/app/components/moduler/button_component.html.erb +7 -0
  11. data/app/components/moduler/button_component.rb +19 -0
  12. data/app/components/moduler/button_link_component.html.erb +5 -0
  13. data/app/components/moduler/button_link_component.rb +15 -0
  14. data/app/components/moduler/filter_component.html.erb +12 -0
  15. data/app/components/moduler/filter_component.rb +15 -0
  16. data/app/components/moduler/form/base_error_component.html.erb +1 -0
  17. data/app/components/moduler/form/base_error_component.rb +9 -0
  18. data/app/components/moduler/form/email_component.html.erb +14 -0
  19. data/app/components/moduler/form/email_component.rb +11 -0
  20. data/app/components/moduler/form/password_component.html.erb +14 -0
  21. data/app/components/moduler/form/password_component.rb +11 -0
  22. data/app/components/moduler/form/section_component.html.erb +5 -0
  23. data/app/components/moduler/form/section_component.rb +14 -0
  24. data/app/components/moduler/form/select_component.html.erb +14 -0
  25. data/app/components/moduler/form/select_component.rb +12 -0
  26. data/app/components/moduler/form/submit_component.html.erb +3 -0
  27. data/app/components/moduler/form/submit_component.rb +10 -0
  28. data/app/components/moduler/form/text_component.html.erb +14 -0
  29. data/app/components/moduler/form/text_component.rb +11 -0
  30. data/app/components/moduler/list_component.html.erb +15 -0
  31. data/app/components/moduler/list_component.rb +18 -0
  32. data/app/components/moduler/list_item_component.html.erb +23 -0
  33. data/app/components/moduler/list_item_component.rb +14 -0
  34. data/app/components/moduler/page_header_component.html.erb +16 -0
  35. data/app/components/moduler/page_header_component.rb +12 -0
  36. data/app/components/moduler/sidebar_action_component.html.erb +5 -0
  37. data/app/components/moduler/sidebar_action_component.rb +15 -0
  38. data/app/controllers/concerns/moduler/authentication.rb +10 -2
  39. data/app/controllers/moduler/administration/application_controller.rb +7 -0
  40. data/app/controllers/moduler/administration/home_controller.rb +9 -0
  41. data/app/controllers/moduler/administration/sessions_controller.rb +34 -0
  42. data/app/controllers/moduler/administration/users_controller.rb +56 -0
  43. data/app/controllers/moduler/application_controller.rb +15 -0
  44. data/app/controllers/moduler/profile/sessions/revocation_controller.rb +14 -0
  45. data/app/controllers/moduler/profile/sessions_controller.rb +21 -0
  46. data/app/controllers/moduler/profile_controller.rb +27 -0
  47. data/app/controllers/moduler/sessions_controller.rb +8 -1
  48. data/app/controllers/moduler/users_controller.rb +7 -0
  49. data/app/filters/application_filter.rb +41 -0
  50. data/app/filters/query_filter.rb +18 -0
  51. data/app/lib/moduler/form_builder.rb +2 -2
  52. data/app/models/moduler/session.rb +12 -0
  53. data/app/models/moduler/user.rb +9 -0
  54. data/app/policies/moduler/administration/base_policy.rb +11 -0
  55. data/app/policies/moduler/administration/home_policy.rb +9 -0
  56. data/app/policies/moduler/administration/session_policy.rb +13 -0
  57. data/app/policies/moduler/administration/user_policy.rb +25 -0
  58. data/app/policies/moduler/application_policy.rb +38 -0
  59. data/app/views/kaminari/_gap.html.erb +1 -0
  60. data/app/views/kaminari/_next_page.html.erb +1 -0
  61. data/app/views/kaminari/_page.html.erb +3 -0
  62. data/app/views/kaminari/_paginator.html.erb +13 -0
  63. data/app/views/kaminari/_prev_page.html.erb +1 -0
  64. data/app/views/layouts/moduler/application.html.erb +5 -6
  65. data/app/views/moduler/administration/home/index.html.erb +6 -0
  66. data/app/views/moduler/administration/sessions/_session.html.erb +30 -0
  67. data/app/views/moduler/administration/sessions/index.html.erb +9 -0
  68. data/app/views/moduler/administration/users/_user.html.erb +13 -0
  69. data/app/views/moduler/administration/users/edit.html.erb +14 -0
  70. data/app/views/moduler/administration/users/index.html.erb +13 -0
  71. data/app/views/moduler/administration/users/new.html.erb +18 -0
  72. data/app/views/moduler/common/_flash.html.erb +16 -0
  73. data/app/views/moduler/common/_header.html.erb +12 -3
  74. data/app/views/moduler/common/_sidebar.html.erb +6 -0
  75. data/app/views/moduler/common/administration/_sidebar.html.erb +6 -0
  76. data/app/views/moduler/profile/edit.html.erb +28 -0
  77. data/app/views/moduler/profile/sessions/_session.html.erb +52 -0
  78. data/app/views/moduler/profile/sessions/index.html.erb +26 -0
  79. data/app/views/moduler/sessions/new.html.erb +4 -18
  80. data/app/views/moduler/users/new.html.erb +5 -32
  81. data/config/importmap.rb +1 -0
  82. data/config/locales/moduler/administration/sessions/en.yml +24 -0
  83. data/config/locales/moduler/administration/users/en.yml +43 -0
  84. data/config/locales/moduler/common/en.yml +5 -0
  85. data/config/locales/moduler/components/en.yml +11 -0
  86. data/config/locales/moduler/en.yml +4 -0
  87. data/config/locales/moduler/profile/en.yml +26 -0
  88. data/config/locales/moduler/profile/sessions/en.yml +25 -0
  89. data/config/locales/moduler/sessions/en.yml +1 -0
  90. data/config/locales/moduler/users/en.yml +7 -1
  91. data/config/routes.rb +14 -1
  92. data/db/migrate/20261015171504_add_role_to_moduler_users.rb +5 -0
  93. data/db/migrate/20261020191709_enable_pg_trgm.rb +5 -0
  94. data/lib/moduler/engine.rb +2 -0
  95. data/lib/moduler/version.rb +1 -1
  96. data/lib/moduler.rb +4 -0
  97. data/lib/tasks/moduler_tasks.rake +2 -4
  98. metadata +151 -5
  99. data/app/assets/javascripts/moduler/controllers/hello_controller.js +0 -6
  100. data/app/controllers/moduler/hello_controller.rb +0 -5
  101. data/app/views/moduler/hello/index.html.erb +0 -5
@@ -0,0 +1,21 @@
1
+ module Moduler
2
+ module Profile
3
+ class SessionsController < ApplicationController
4
+ def index
5
+ @sessions = Current.user.sessions.order(created_at: :desc).page(params[:page]).per(10)
6
+ end
7
+
8
+ def destroy
9
+ session = Current.user.sessions.active.find(params[:id])
10
+ session.touch(:signed_out_at)
11
+
12
+ if session == Current.session
13
+ cookies.delete(:session_id)
14
+ redirect_to moduler.new_session_path, status: :see_other
15
+ else
16
+ redirect_to moduler.profile_sessions_path, status: :see_other
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,27 @@
1
+ module Moduler
2
+ class ProfileController < ApplicationController
3
+ def edit
4
+ @user = Current.user
5
+ end
6
+
7
+ def update
8
+ @user = Current.user
9
+
10
+ @user.assign_attributes(permitted_parameters)
11
+
12
+ if @user.save(context: :self_service_update)
13
+ @user.sessions.active.where.not(id: Current.session.id).update_all(signed_out_at: Time.current) if @user.saved_change_to_password_digest?
14
+
15
+ redirect_to moduler.edit_profile_path, notice: t(".notice")
16
+ else
17
+ render :edit, status: :unprocessable_entity
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def permitted_parameters
24
+ params.expect(user: %i[ password_challenge password password_confirmation ])
25
+ end
26
+ end
27
+ end
@@ -2,6 +2,13 @@ module Moduler
2
2
  class SessionsController < ApplicationController
3
3
  allow_unauthenticated_access only: %i[ new create ]
4
4
 
5
+ rate_limit to: 10, within: 3.minutes, only: :create,
6
+ with: -> { redirect_to moduler.new_session_path, alert: t("moduler.sessions.create.rate_limited") }
7
+
8
+ rate_limit to: 5, within: 3.minutes, only: :create,
9
+ by: -> { params.dig(:user, :email_address) },
10
+ with: -> { redirect_to moduler.new_session_path, alert: t("moduler.sessions.create.rate_limited") }
11
+
5
12
  def new
6
13
  if authenticated?
7
14
  redirect_to after_authentication_url
@@ -26,7 +33,7 @@ module Moduler
26
33
 
27
34
  def destroy
28
35
  terminate_session
29
- redirect_to moduler.new_session_path
36
+ redirect_to moduler.new_session_path, status: :see_other
30
37
  end
31
38
  end
32
39
  end
@@ -2,6 +2,13 @@ module Moduler
2
2
  class UsersController < ApplicationController
3
3
  allow_unauthenticated_access only: %i[ new create ]
4
4
 
5
+ rate_limit to: 10, within: 3.minutes, only: :create,
6
+ with: -> { redirect_to moduler.new_user_path, alert: t("moduler.users.create.rate_limited") }
7
+
8
+ rate_limit to: 5, within: 3.minutes, only: :create,
9
+ by: -> { params.dig(:user, :email_address) },
10
+ with: -> { redirect_to moduler.new_user_path, alert: t("moduler.users.create.rate_limited") }
11
+
5
12
  def new
6
13
  if authenticated?
7
14
  redirect_to after_authentication_url
@@ -0,0 +1,41 @@
1
+ class ApplicationFilter
2
+ include ActiveModel::Model
3
+ include ActiveModel::Dirty
4
+ include ActiveModel::Attributes
5
+
6
+ class << self
7
+ def modifiers
8
+ @modifiers ||= []
9
+ end
10
+ end
11
+
12
+ def initialize(scope, parameters, options = {})
13
+ super(parameters)
14
+
15
+ @scope = scope
16
+ @options = options
17
+ end
18
+
19
+ def call
20
+ [ filter, results ]
21
+ end
22
+
23
+ private
24
+
25
+ def filter
26
+ return self unless changed?
27
+
28
+ tap(&:valid?)
29
+ end
30
+
31
+ def results
32
+ return @scope.none.page(1) if !changed? && @options.fetch(:allow_empty, false)
33
+ return @scope.none.page(1) if invalid?
34
+
35
+ self.class.modifiers.each do |modifier|
36
+ @scope = modifier.call(@scope, self) || @scope
37
+ end
38
+
39
+ @scope
40
+ end
41
+ end
@@ -0,0 +1,18 @@
1
+ class QueryFilter < ApplicationFilter
2
+ attribute :query, :string
3
+ attribute :page, :integer
4
+
5
+ validates :query, length: { maximum: 100 }
6
+
7
+ modifiers << proc do |scope, filter|
8
+ scope.page(filter.page).per(10)
9
+ end
10
+
11
+ modifiers << proc do |scope, _filter|
12
+ scope.order(created_at: :desc)
13
+ end
14
+
15
+ modifiers << proc do |scope, filter|
16
+ scope.search_by_query(filter.query) if filter.query.present?
17
+ end
18
+ end
@@ -5,7 +5,7 @@ module Moduler
5
5
 
6
6
  @template.content_tag :div, **options do
7
7
  @object.errors[field].first
8
- end.html_safe
8
+ end
9
9
  end
10
10
 
11
11
  def full_error(field, options = {})
@@ -13,7 +13,7 @@ module Moduler
13
13
 
14
14
  @template.content_tag :div, **options do
15
15
  @object.errors.full_messages_for(field).first
16
- end.html_safe
16
+ end
17
17
  end
18
18
 
19
19
  def error?(field)
@@ -1,5 +1,17 @@
1
1
  module Moduler
2
2
  class Session < ApplicationRecord
3
+ include PgSearch::Model
4
+
5
+ LIFETIME = 30.days
6
+
3
7
  belongs_to :user, class_name: "Moduler::User", foreign_key: "moduler_user_id"
8
+
9
+ scope :active, -> { where(signed_out_at: nil) }
10
+
11
+ pg_search_scope :search_by_query, against: %i[ ip_address user_agent ], associated_against: { user: %i[ email_address ] }, using: { trigram: { word_similarity: true } }
12
+
13
+ def active?
14
+ signed_out_at.nil?
15
+ end
4
16
  end
5
17
  end
@@ -1,9 +1,18 @@
1
1
  module Moduler
2
2
  class User < ApplicationRecord
3
+ include PgSearch::Model
4
+
3
5
  has_many :sessions, class_name: "Moduler::Session", foreign_key: "moduler_user_id", dependent: :destroy
4
6
 
5
7
  has_secure_password
6
8
 
9
+ normalizes :email_address, with: ->(email_address) { email_address.strip.downcase }
10
+
7
11
  validates :email_address, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
12
+ validates :password, length: { minimum: 8 }, allow_nil: true
13
+
14
+ enum :role, { member: 0, administrator: 1 }
15
+
16
+ pg_search_scope :search_by_query, against: %i[ email_address ], using: { trigram: { word_similarity: true } }
8
17
  end
9
18
  end
@@ -0,0 +1,11 @@
1
+ module Moduler
2
+ module Administration
3
+ class BasePolicy < ApplicationPolicy
4
+ private
5
+
6
+ def administrator?
7
+ user&.administrator?
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,9 @@
1
+ module Moduler
2
+ module Administration
3
+ class HomePolicy < BasePolicy
4
+ def index?
5
+ administrator?
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,13 @@
1
+ module Moduler
2
+ module Administration
3
+ class SessionPolicy < BasePolicy
4
+ def index?
5
+ administrator?
6
+ end
7
+
8
+ def destroy?
9
+ administrator?
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,25 @@
1
+ module Moduler
2
+ module Administration
3
+ class UserPolicy < BasePolicy
4
+ def index?
5
+ administrator?
6
+ end
7
+
8
+ def create?
9
+ administrator?
10
+ end
11
+
12
+ def update?
13
+ administrator? && record != user
14
+ end
15
+
16
+ def new?
17
+ create?
18
+ end
19
+
20
+ def edit?
21
+ update?
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,38 @@
1
+ module Moduler
2
+ class ApplicationPolicy
3
+ attr_reader :user, :record
4
+
5
+ def initialize(user, record)
6
+ @user = user
7
+ @record = record
8
+ end
9
+
10
+ def index?
11
+ false
12
+ end
13
+
14
+ def show?
15
+ false
16
+ end
17
+
18
+ def create?
19
+ false
20
+ end
21
+
22
+ def new?
23
+ create?
24
+ end
25
+
26
+ def update?
27
+ false
28
+ end
29
+
30
+ def edit?
31
+ update?
32
+ end
33
+
34
+ def destroy?
35
+ false
36
+ end
37
+ end
38
+ end
@@ -0,0 +1 @@
1
+ <span class="px-2 text-slate-400"><%= raw(t("views.pagination.truncate")) %></span>
@@ -0,0 +1 @@
1
+ <%= link_to raw(t("views.pagination.next")), url, rel: "next", remote: remote, class: "rounded px-3 py-1 text-slate-600 hover:bg-slate-100" %>
@@ -0,0 +1,3 @@
1
+ <%= link_to page, url, remote: remote, rel: page.rel,
2
+ class: "rounded px-3 py-1 #{page.current? ? 'bg-slate-900 text-white' : 'text-slate-600 hover:bg-slate-100'}",
3
+ "aria-current": (page.current? ? "page" : nil) %>
@@ -0,0 +1,13 @@
1
+ <%= paginator.render do %>
2
+ <nav class="flex flex-wrap items-center justify-end gap-1 text-sm" aria-label="Pagination">
3
+ <%= prev_page_tag unless current_page.first? %>
4
+ <% each_page do |page| %>
5
+ <% if page.left_outer? || page.right_outer? || page.inside_window? %>
6
+ <%= page_tag page %>
7
+ <% elsif !page.was_truncated? %>
8
+ <%= gap_tag %>
9
+ <% end %>
10
+ <% end %>
11
+ <%= next_page_tag unless current_page.last? %>
12
+ </nav>
13
+ <% end %>
@@ -0,0 +1 @@
1
+ <%= link_to raw(t("views.pagination.previous")), url, rel: "prev", remote: remote, class: "rounded px-3 py-1 text-slate-600 hover:bg-slate-100" %>
@@ -2,6 +2,7 @@
2
2
  <html>
3
3
  <head>
4
4
  <title>Moduler</title>
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
5
6
  <%= csrf_meta_tags %>
6
7
  <%= csp_meta_tag %>
7
8
 
@@ -9,14 +10,12 @@
9
10
 
10
11
  <%= stylesheet_link_tags_if_exist "moduler/tailwind", "moduler/application", media: "all" %>
11
12
 
12
- <%# Include host application stylesheets %>
13
- <%= stylesheet_link_tags_if_exist "tailwind", "application", media: "all" %>
14
-
15
13
  <%= javascript_importmap_tags "moduler/application" %>
16
14
  </head>
17
- <body>
18
- <div class="min-h-full">
19
- <%= render "moduler/common/header" %>
15
+ <body class="flex min-h-screen flex-col">
16
+ <div class="flex flex-1 flex-col">
17
+ <%= render "moduler/common/header" if authenticated? %>
18
+ <%= render "moduler/common/flash" %>
20
19
 
21
20
  <%= yield %>
22
21
  </div>
@@ -0,0 +1,6 @@
1
+ <main class="mx-auto flex w-full flex-1 max-w-[120rem] flex-col gap-4 px-4 md:flex-row md:gap-8 md:pl-0 md:pr-6">
2
+ <%= render "moduler/common/administration/sidebar" %>
3
+
4
+ <div class="flex min-w-0 flex-1 flex-col pb-4 md:mt-8">
5
+ </div>
6
+ </main>
@@ -0,0 +1,30 @@
1
+ <% current = session == Moduler::Current.session %>
2
+
3
+ <%= render(Moduler::ListItemComponent.new(session.user.email_address, id: dom_id(session))) do |item| %>
4
+ <% if current %>
5
+ <% item.with_badge(t(".status.current"), variant: :success) %>
6
+ <% elsif session.active? %>
7
+ <% item.with_badge(t(".status.active")) %>
8
+ <% else %>
9
+ <% item.with_badge(t(".status.signed_out"), variant: :muted) %>
10
+ <% end %>
11
+
12
+ <% item.with_meta do %>
13
+ <%= session.ip_address.presence || t(".unknown_ip") %>
14
+ <span aria-hidden="true">&middot;</span>
15
+ <span title="<%= session.user_agent %>"><%= truncate(session.user_agent.presence || t(".unknown_device"), length: 60) %></span>
16
+ <span aria-hidden="true">&middot;</span>
17
+ <%= time_tag session.signed_in_at, t(".signed_in", time: time_ago_in_words(session.signed_in_at)), title: l(session.signed_in_at, format: :long) %>
18
+ <% if session.signed_out_at %>
19
+ <span aria-hidden="true">&middot;</span>
20
+ <%= time_tag session.signed_out_at, t(".signed_out", time: time_ago_in_words(session.signed_out_at)), title: l(session.signed_out_at, format: :long) %>
21
+ <% end %>
22
+ <% end %>
23
+
24
+ <% if session.active? %>
25
+ <% item.with_action do %>
26
+ <%= render(Moduler::ButtonComponent.new(t(".action.destroy"), moduler.administration_session_path(session), method: :delete, size: :sm,
27
+ confirm: t(current ? ".confirmation.destroy_current" : ".confirmation.destroy"))) %>
28
+ <% end %>
29
+ <% end %>
30
+ <% end %>
@@ -0,0 +1,9 @@
1
+ <%= render(Moduler::Administration::PageComponent.new) do %>
2
+ <%= render(Moduler::PageHeaderComponent.new(t(".title"), description: t(".description"))) %>
3
+
4
+ <%= render(Moduler::FilterComponent.new(@filter, moduler.administration_sessions_path)) %>
5
+
6
+ <%= render(Moduler::ListComponent.new(@sessions, id: "sessions", empty: t(".empty"))) do %>
7
+ <%= render partial: "moduler/administration/sessions/session", collection: @sessions %>
8
+ <% end %>
9
+ <% end %>
@@ -0,0 +1,13 @@
1
+ <%= render(Moduler::ListItemComponent.new(user.email_address, id: dom_id(user))) do |item| %>
2
+ <% item.with_badge(t("moduler.administration.users.roles.#{user.role}"), variant: user.administrator? ? :success : :neutral) %>
3
+
4
+ <% item.with_meta do %>
5
+ <%= time_tag user.created_at, t(".created", time: time_ago_in_words(user.created_at)), title: l(user.created_at, format: :long) %>
6
+ <% end %>
7
+
8
+ <% if Moduler::Administration::UserPolicy.new(current_user, user).edit? %>
9
+ <% item.with_action do %>
10
+ <%= render(Moduler::ButtonLinkComponent.new(t(".action.edit"), moduler.edit_administration_user_path(user), size: :sm)) %>
11
+ <% end %>
12
+ <% end %>
13
+ <% end %>
@@ -0,0 +1,14 @@
1
+ <%= render(Moduler::Administration::PageComponent.new) do %>
2
+ <%= render(Moduler::PageHeaderComponent.new(t(".title"))) %>
3
+
4
+ <%= form_with model: @user, url: moduler.administration_user_path(@user), method: :patch, data: { turbo: false }, class: "w-full max-w-lg space-y-6" do |form| %>
5
+ <%= render(::Moduler::Form::BaseErrorComponent.new(form)) %>
6
+
7
+ <%= render(::Moduler::Form::SectionComponent.new(t(".section.data"))) do %>
8
+ <%= render(::Moduler::Form::EmailComponent.new(form, :email_address, label: t(".label.email_address"))) %>
9
+ <%= render(::Moduler::Form::SelectComponent.new(form, :role, Moduler::User.roles.keys.map { |role| [ t("moduler.administration.users.roles.#{role}"), role ] }, label: t(".label.role"))) %>
10
+ <% end %>
11
+
12
+ <%= render(::Moduler::Form::SubmitComponent.new(form, text: t(".action.save"))) %>
13
+ <% end %>
14
+ <% end %>
@@ -0,0 +1,13 @@
1
+ <%= render(Moduler::Administration::PageComponent.new) do %>
2
+ <%= render(Moduler::PageHeaderComponent.new(t(".title"), description: t(".description"))) do |header| %>
3
+ <% header.with_action do %>
4
+ <%= render(Moduler::ButtonLinkComponent.new(t(".action.new"), moduler.new_administration_user_path)) %>
5
+ <% end %>
6
+ <% end %>
7
+
8
+ <%= render(Moduler::FilterComponent.new(@filter, moduler.administration_users_path)) %>
9
+
10
+ <%= render(Moduler::ListComponent.new(@users, id: "users", empty: t(".empty"))) do %>
11
+ <%= render partial: "moduler/administration/users/user", collection: @users %>
12
+ <% end %>
13
+ <% end %>
@@ -0,0 +1,18 @@
1
+ <%= render(Moduler::Administration::PageComponent.new) do %>
2
+ <%= render(Moduler::PageHeaderComponent.new(t(".title"))) %>
3
+
4
+ <%= form_with model: @user, url: moduler.administration_users_path, data: { turbo: false }, class: "w-full max-w-lg space-y-6" do |form| %>
5
+ <%= render(::Moduler::Form::BaseErrorComponent.new(form)) %>
6
+
7
+ <%= render(::Moduler::Form::SectionComponent.new(t(".section.data"))) do %>
8
+ <%= render(::Moduler::Form::EmailComponent.new(form, :email_address, label: t(".label.email_address"))) %>
9
+ <%= render(::Moduler::Form::SelectComponent.new(form, :role, Moduler::User.roles.keys.map { |role| [ t("moduler.administration.users.roles.#{role}"), role ] }, label: t(".label.role"))) %>
10
+ <% end %>
11
+
12
+ <%= render(::Moduler::Form::SectionComponent.new(t(".section.password"), divider: true)) do %>
13
+ <%= render(::Moduler::Form::PasswordComponent.new(form, :password, label: t(".label.password"), description: t(".description.password"))) %>
14
+ <% end %>
15
+
16
+ <%= render(::Moduler::Form::SubmitComponent.new(form, text: t(".action.create"))) %>
17
+ <% end %>
18
+ <% end %>
@@ -0,0 +1,16 @@
1
+ <% if flash.any? { |_, message| message.present? } %>
2
+ <div class="fixed top-4 right-4 z-50 flex w-full max-w-sm flex-col gap-2 px-4 sm:px-0">
3
+ <% flash.each do |key, message| %>
4
+ <% next if message.blank? %>
5
+ <div data-controller="flash"
6
+ class="flex items-start gap-3 rounded-md border px-4 py-3 text-sm shadow-lg transition duration-200 <%= key.to_s.in?(%w[alert error]) ? "border-red-200 bg-red-50 text-red-700" : key.to_s == "warning" ? "border-amber-200 bg-amber-50 text-amber-700" : "border-emerald-200 bg-emerald-50 text-emerald-700" %>">
7
+ <p class="flex-1"><%= message %></p>
8
+ <button type="button" data-action="flash#dismiss" class="shrink-0 cursor-pointer text-slate-400 transition hover:text-slate-600">
9
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" class="h-4 w-4">
10
+ <path d="M18 6 6 18M6 6l12 12" />
11
+ </svg>
12
+ </button>
13
+ </div>
14
+ <% end %>
15
+ </div>
16
+ <% end %>
@@ -1,9 +1,18 @@
1
1
  <nav class="border-b border-gray-200 bg-white">
2
- <div class="mx-auto flex h-16 max-w-7xl items-center justify-between px-4 sm:px-6 lg:px-8">
3
- <div class="flex items-center gap-8">
2
+ <div class="flex h-16 w-full items-center justify-between gap-4 px-4 sm:px-6 lg:px-6">
3
+ <div class="flex shrink-0 items-center gap-4 sm:gap-8">
4
4
  <%= link_to t(".nav.home"), moduler.root_path, class: "text-sm/6 font-semibold text-gray-900 hover:text-custom" %>
5
+
6
+ <% if policy([:moduler, :administration, :home]).index? %>
7
+ |
8
+ <%= link_to t(".nav.administration"), moduler.administration_root_path, class: "text-sm/6 font-semibold text-gray-900 hover:text-custom" %>
9
+ <% end %>
5
10
  </div>
6
11
 
7
- <%= button_to t(".action.sign_out"), moduler.session_path, method: :delete, class: "cursor-pointer rounded-md px-3 py-1.5 text-sm/6 font-semibold text-gray-900 hover:bg-gray-50" %>
12
+ <div class="flex min-w-0 items-center gap-3 text-sm text-gray-900 sm:gap-6">
13
+ <%= link_to current_user.email_address, moduler.edit_profile_path, class: "truncate" %>
14
+ |
15
+ <%= button_to t(".action.sign_out"), moduler.session_path, method: :delete, class: "cursor-pointer whitespace-nowrap font-semibold" %>
16
+ </div>
8
17
  </div>
9
18
  </nav>
@@ -0,0 +1,6 @@
1
+ <aside class="-mx-4 border-b border-gray-200 px-2 md:mx-0 md:w-1/8 md:shrink-0 md:border-b-0 md:border-r md:px-0">
2
+ <nav class="flex gap-1 overflow-x-auto md:flex-col text-sm p-2">
3
+ <%= render(Moduler::SidebarActionComponent.new(t(".action.profile"), moduler.edit_profile_path, "moduler/profile")) %>
4
+ <%= render(Moduler::SidebarActionComponent.new(t(".action.sessions"), moduler.profile_sessions_path, "moduler/profile/sessions")) %>
5
+ </nav>
6
+ </aside>
@@ -0,0 +1,6 @@
1
+ <aside class="-mx-4 border-b border-gray-200 px-2 md:mx-0 md:w-1/8 md:shrink-0 md:border-b-0 md:border-r md:px-0">
2
+ <nav class="flex gap-1 overflow-x-auto md:flex-col text-sm p-2">
3
+ <%= render(Moduler::SidebarActionComponent.new(t(".action.users"), moduler.administration_users_path, "moduler/administration/users")) %>
4
+ <%= render(Moduler::SidebarActionComponent.new(t(".action.sessions"), moduler.administration_sessions_path, "moduler/administration/sessions")) %>
5
+ </nav>
6
+ </aside>
@@ -0,0 +1,28 @@
1
+ <main class="mx-auto flex w-full flex-1 max-w-[120rem] flex-col gap-4 px-4 md:flex-row md:gap-8 md:pl-0 md:pr-6">
2
+ <%= render "moduler/common/sidebar" %>
3
+
4
+ <div class="flex min-w-0 flex-1 flex-col pb-4 md:mt-8">
5
+ <div class="mb-4 flex min-h-9 items-center justify-between gap-4">
6
+ <h1 class="text-base font-semibold text-gray-900"><%= t(".title") %></h1>
7
+ </div>
8
+
9
+ <%= form_with model: @user, url: moduler.profile_path, method: :patch, data: { turbo: false }, class: "w-full max-w-lg space-y-6" do |form| %>
10
+ <%= render(::Moduler::Form::BaseErrorComponent.new(form)) %>
11
+
12
+ <%= render(::Moduler::Form::SectionComponent.new(t(".section.data"))) do %>
13
+ <%= render(::Moduler::Form::EmailComponent.new(form, :email_address, label: t(".label.email_address"), disabled: :disabled)) %>
14
+ <% end %>
15
+
16
+ <%= render(::Moduler::Form::SectionComponent.new(t(".section.password"), divider: true)) do %>
17
+ <%= render(::Moduler::Form::PasswordComponent.new(form, :password, label: t(".label.password"))) %>
18
+ <%= render(::Moduler::Form::PasswordComponent.new(form, :password_confirmation, label: t(".label.password_confirmation"), description: t(".description.password_confirmation"))) %>
19
+ <% end %>
20
+
21
+ <%= render(::Moduler::Form::SectionComponent.new(t(".section.current_password"), divider: true)) do %>
22
+ <%= render(::Moduler::Form::PasswordComponent.new(form, :password_challenge, label: t(".label.password_challenge"), description: t(".description.password_challenge"))) %>
23
+ <% end %>
24
+
25
+ <%= render(::Moduler::Form::SubmitComponent.new(form, text: t(".action.save"))) %>
26
+ <% end %>
27
+ </div>
28
+ </main>
@@ -0,0 +1,52 @@
1
+ <% current = session == Moduler::Current.session %>
2
+ <% mobile = session.user_agent.to_s.match?(/Mobile|Android|iPhone|iPad/) %>
3
+
4
+ <li id="<%= dom_id(session) %>" class="flex items-center justify-between gap-4 px-4 py-4 sm:px-5 <%= "bg-slate-50/60" unless session.active? %>">
5
+ <div class="flex min-w-0 items-start gap-4">
6
+ <div class="flex size-10 shrink-0 items-center justify-center rounded-full <%= session.active? ? "bg-slate-100 text-slate-600" : "bg-slate-100/60 text-slate-400" %>">
7
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="size-5" aria-hidden="true">
8
+ <% if mobile %>
9
+ <rect x="7" y="2.5" width="10" height="19" rx="2" />
10
+ <path d="M11 18.5h2" />
11
+ <% else %>
12
+ <rect x="3" y="4" width="18" height="12" rx="1.5" />
13
+ <path d="M8 20h8M12 16v4" />
14
+ <% end %>
15
+ </svg>
16
+ </div>
17
+
18
+ <div class="min-w-0">
19
+ <div class="flex flex-wrap items-center gap-2">
20
+ <p class="text-sm font-semibold <%= session.active? ? "text-gray-900" : "text-slate-500" %>">
21
+ <%= session.ip_address.presence || t(".unknown_ip") %>
22
+ </p>
23
+
24
+ <% if current %>
25
+ <span class="inline-flex items-center rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-medium text-emerald-700 ring-1 ring-inset ring-emerald-600/20"><%= t(".status.current") %></span>
26
+ <% elsif session.active? %>
27
+ <span class="inline-flex items-center rounded-full bg-slate-50 px-2 py-0.5 text-xs font-medium text-slate-600 ring-1 ring-inset ring-slate-500/20"><%= t(".status.active") %></span>
28
+ <% else %>
29
+ <span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium text-slate-400 ring-1 ring-inset ring-slate-200"><%= t(".status.signed_out") %></span>
30
+ <% end %>
31
+ </div>
32
+
33
+ <p class="mt-1 truncate text-xs text-slate-500" title="<%= session.user_agent %>">
34
+ <%= session.user_agent.presence || t(".unknown_device") %>
35
+ </p>
36
+
37
+ <p class="mt-1 text-xs text-slate-400">
38
+ <%= time_tag session.signed_in_at, t(".signed_in", time: time_ago_in_words(session.signed_in_at)), title: l(session.signed_in_at, format: :long) %>
39
+ <% if session.signed_out_at %>
40
+ <span aria-hidden="true">&middot;</span>
41
+ <%= time_tag session.signed_out_at, t(".signed_out", time: time_ago_in_words(session.signed_out_at)), title: l(session.signed_out_at, format: :long) %>
42
+ <% end %>
43
+ </p>
44
+ </div>
45
+ </div>
46
+
47
+ <% if session.active? %>
48
+ <%= button_to t(".action.destroy"), moduler.profile_session_path(session), method: :delete,
49
+ class: "cursor-pointer rounded-md bg-white px-2.5 py-1 text-sm font-medium text-slate-600 shadow-sm ring-1 ring-inset ring-slate-300 hover:bg-slate-50 hover:text-red-600",
50
+ form: { class: "shrink-0", data: { turbo_confirm: t(current ? ".confirmation.destroy_current" : ".confirmation.destroy") } } %>
51
+ <% end %>
52
+ </li>
@@ -0,0 +1,26 @@
1
+ <main class="mx-auto flex w-full flex-1 max-w-[120rem] flex-col gap-4 px-4 md:flex-row md:gap-8 md:pl-0 md:pr-6">
2
+ <%= render "moduler/common/sidebar" %>
3
+
4
+ <div class="flex min-w-0 flex-1 flex-col pb-4 md:mt-8">
5
+ <div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
6
+ <div>
7
+ <h1 class="text-base font-semibold text-gray-900"><%= t(".title") %></h1>
8
+ <p class="mt-1 text-sm text-slate-500"><%= t(".description") %></p>
9
+ </div>
10
+
11
+ <%= button_to t(".action.revoke_all"), moduler.profile_sessions_revocation_path, method: :delete,
12
+ class: "cursor-pointer whitespace-nowrap rounded-md bg-white px-3 py-1.5 text-sm/6 font-semibold text-red-600 shadow-sm ring-1 ring-inset ring-slate-300 hover:bg-red-50 hover:ring-red-200 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600",
13
+ form: { class: "shrink-0", data: { turbo_confirm: t(".confirmation.revoke") } } %>
14
+ </div>
15
+
16
+ <div class="flex-1">
17
+ <ul id="sessions" role="list" class="divide-y divide-slate-100 rounded-lg border border-slate-200 bg-white">
18
+ <%= render partial: "moduler/profile/sessions/session", collection: @sessions %>
19
+ </ul>
20
+ </div>
21
+
22
+ <div class="mt-4 flex justify-end">
23
+ <%= paginate @sessions %>
24
+ </div>
25
+ </div>
26
+ </main>