ruby_cms 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 (131) hide show
  1. checksums.yaml +7 -0
  2. data/.cursor/dhh.mdc +698 -0
  3. data/.ruby-version +1 -0
  4. data/CHANGELOG.md +5 -0
  5. data/CODE_OF_CONDUCT.md +10 -0
  6. data/README.md +235 -0
  7. data/Rakefile +30 -0
  8. data/app/components/ruby_cms/admin/admin_page/admin_table_content.rb +32 -0
  9. data/app/components/ruby_cms/admin/admin_page.rb +345 -0
  10. data/app/components/ruby_cms/admin/base_component.rb +78 -0
  11. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table.rb +149 -0
  12. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_actions.rb +127 -0
  13. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_body.rb +15 -0
  14. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_checkbox_cell.rb +41 -0
  15. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_checkbox_head.rb +33 -0
  16. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_delete_modal.rb +174 -0
  17. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_header.rb +59 -0
  18. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_header_bar.rb +159 -0
  19. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_pagination.rb +192 -0
  20. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_row.rb +97 -0
  21. data/app/components/ruby_cms/admin/bulk_action_table/bulk_actions.rb +137 -0
  22. data/app/controllers/concerns/ruby_cms/admin_pagination.rb +120 -0
  23. data/app/controllers/concerns/ruby_cms/admin_turbo_table.rb +68 -0
  24. data/app/controllers/concerns/ruby_cms/page_tracking.rb +52 -0
  25. data/app/controllers/concerns/ruby_cms/visitor_error_capture.rb +39 -0
  26. data/app/controllers/ruby_cms/admin/analytics_controller.rb +191 -0
  27. data/app/controllers/ruby_cms/admin/base_controller.rb +105 -0
  28. data/app/controllers/ruby_cms/admin/content_blocks_controller.rb +390 -0
  29. data/app/controllers/ruby_cms/admin/dashboard_controller.rb +50 -0
  30. data/app/controllers/ruby_cms/admin/locale_controller.rb +20 -0
  31. data/app/controllers/ruby_cms/admin/permissions_controller.rb +66 -0
  32. data/app/controllers/ruby_cms/admin/settings_controller.rb +223 -0
  33. data/app/controllers/ruby_cms/admin/user_permissions_controller.rb +59 -0
  34. data/app/controllers/ruby_cms/admin/users_controller.rb +107 -0
  35. data/app/controllers/ruby_cms/admin/visitor_errors_controller.rb +89 -0
  36. data/app/controllers/ruby_cms/admin/visual_editor_controller.rb +322 -0
  37. data/app/controllers/ruby_cms/errors_controller.rb +35 -0
  38. data/app/helpers/ruby_cms/admin/admin_page_helper.rb +21 -0
  39. data/app/helpers/ruby_cms/admin/bulk_action_table_helper.rb +159 -0
  40. data/app/helpers/ruby_cms/application_helper.rb +41 -0
  41. data/app/helpers/ruby_cms/bulk_action_table_helper.rb +151 -0
  42. data/app/helpers/ruby_cms/content_blocks_helper.rb +375 -0
  43. data/app/helpers/ruby_cms/settings_helper.rb +160 -0
  44. data/app/javascript/controllers/ruby_cms/auto_save_preference_controller.js +73 -0
  45. data/app/javascript/controllers/ruby_cms/bulk_action_table_controller.js +553 -0
  46. data/app/javascript/controllers/ruby_cms/clickable_row_controller.js +28 -0
  47. data/app/javascript/controllers/ruby_cms/flash_messages_controller.js +29 -0
  48. data/app/javascript/controllers/ruby_cms/index.js +104 -0
  49. data/app/javascript/controllers/ruby_cms/locale_tabs_controller.js +34 -0
  50. data/app/javascript/controllers/ruby_cms/mobile_menu_controller.js +55 -0
  51. data/app/javascript/controllers/ruby_cms/nav_order_sortable_controller.js +192 -0
  52. data/app/javascript/controllers/ruby_cms/page_preview_controller.js +135 -0
  53. data/app/javascript/controllers/ruby_cms/toggle_controller.js +39 -0
  54. data/app/javascript/controllers/ruby_cms/visual_editor_controller.js +321 -0
  55. data/app/models/concerns/content_block/publishable.rb +54 -0
  56. data/app/models/concerns/content_block/searchable.rb +22 -0
  57. data/app/models/content_block.rb +155 -0
  58. data/app/models/ruby_cms/content_block.rb +8 -0
  59. data/app/models/ruby_cms/permission.rb +28 -0
  60. data/app/models/ruby_cms/permittable.rb +39 -0
  61. data/app/models/ruby_cms/preference.rb +111 -0
  62. data/app/models/ruby_cms/user_permission.rb +12 -0
  63. data/app/models/ruby_cms/visitor_error.rb +109 -0
  64. data/app/services/ruby_cms/analytics/report.rb +362 -0
  65. data/app/services/ruby_cms/security_tracker.rb +92 -0
  66. data/app/views/layouts/ruby_cms/_admin_flash_messages.html.erb +37 -0
  67. data/app/views/layouts/ruby_cms/_admin_sidebar.html.erb +121 -0
  68. data/app/views/layouts/ruby_cms/admin.html.erb +81 -0
  69. data/app/views/layouts/ruby_cms/minimal.html.erb +181 -0
  70. data/app/views/ruby_cms/admin/analytics/index.html.erb +160 -0
  71. data/app/views/ruby_cms/admin/analytics/page_details.html.erb +84 -0
  72. data/app/views/ruby_cms/admin/analytics/partials/_back_button.html.erb +3 -0
  73. data/app/views/ruby_cms/admin/analytics/partials/_browser_device.html.erb +40 -0
  74. data/app/views/ruby_cms/admin/analytics/partials/_daily_activity_chart.html.erb +58 -0
  75. data/app/views/ruby_cms/admin/analytics/partials/_hourly_activity_chart.html.erb +51 -0
  76. data/app/views/ruby_cms/admin/analytics/partials/_recent_activity.html.erb +31 -0
  77. data/app/views/ruby_cms/admin/analytics/partials/_security_alert.html.erb +4 -0
  78. data/app/views/ruby_cms/admin/analytics/partials/_top_referrers.html.erb +21 -0
  79. data/app/views/ruby_cms/admin/analytics/visitor_details.html.erb +125 -0
  80. data/app/views/ruby_cms/admin/content_blocks/_form.html.erb +161 -0
  81. data/app/views/ruby_cms/admin/content_blocks/_row.html.erb +25 -0
  82. data/app/views/ruby_cms/admin/content_blocks/edit.html.erb +17 -0
  83. data/app/views/ruby_cms/admin/content_blocks/index.html.erb +66 -0
  84. data/app/views/ruby_cms/admin/content_blocks/new.html.erb +5 -0
  85. data/app/views/ruby_cms/admin/content_blocks/show.html.erb +110 -0
  86. data/app/views/ruby_cms/admin/dashboard/index.html.erb +198 -0
  87. data/app/views/ruby_cms/admin/permissions/_row.html.erb +11 -0
  88. data/app/views/ruby_cms/admin/permissions/index.html.erb +62 -0
  89. data/app/views/ruby_cms/admin/settings/index.html.erb +220 -0
  90. data/app/views/ruby_cms/admin/shared/_bulk_action_table_index.html.erb +56 -0
  91. data/app/views/ruby_cms/admin/user_permissions/index.html.erb +55 -0
  92. data/app/views/ruby_cms/admin/users/_row.html.erb +14 -0
  93. data/app/views/ruby_cms/admin/users/index.html.erb +70 -0
  94. data/app/views/ruby_cms/admin/visitor_errors/_row.html.erb +35 -0
  95. data/app/views/ruby_cms/admin/visitor_errors/index.html.erb +57 -0
  96. data/app/views/ruby_cms/admin/visitor_errors/show.html.erb +147 -0
  97. data/app/views/ruby_cms/admin/visual_editor/index.html.erb +144 -0
  98. data/app/views/ruby_cms/errors/not_found.html.erb +92 -0
  99. data/config/database.yml +6 -0
  100. data/config/importmap.rb +36 -0
  101. data/config/locales/en.yml +101 -0
  102. data/config/routes.rb +65 -0
  103. data/db/migrate/20260125000001_create_ruby_cms_permissions.rb +14 -0
  104. data/db/migrate/20260125000002_create_ruby_cms_user_permissions.rb +14 -0
  105. data/db/migrate/20260125000003_create_ruby_cms_content_blocks.rb +19 -0
  106. data/db/migrate/20260125000010_add_indexes_to_ruby_cms_tables.rb +9 -0
  107. data/db/migrate/20260127000001_add_locale_to_ruby_cms_content_blocks.rb +34 -0
  108. data/db/migrate/20260129000001_create_ruby_cms_visitor_errors.rb +24 -0
  109. data/db/migrate/20260130000001_add_referer_and_query_to_ruby_cms_visitor_errors.rb +8 -0
  110. data/db/migrate/20260130000002_create_ruby_cms_preferences.rb +16 -0
  111. data/db/migrate/20260130000003_add_category_to_ruby_cms_preferences.rb +8 -0
  112. data/db/migrate/20260211000001_add_ruby_cms_analytics_fields_to_ahoy_events.rb +19 -0
  113. data/db/migrate/20260212000001_use_unprefixed_cms_tables.rb +146 -0
  114. data/exe/ruby_cms +25 -0
  115. data/lib/generators/ruby_cms/install_generator.rb +1062 -0
  116. data/lib/generators/ruby_cms/templates/admin.html.erb +82 -0
  117. data/lib/generators/ruby_cms/templates/ruby_cms.rb +86 -0
  118. data/lib/ruby_cms/app_integration.rb +82 -0
  119. data/lib/ruby_cms/cli.rb +169 -0
  120. data/lib/ruby_cms/content_blocks_grouping.rb +41 -0
  121. data/lib/ruby_cms/content_blocks_sync.rb +329 -0
  122. data/lib/ruby_cms/css_compiler.rb +35 -0
  123. data/lib/ruby_cms/engine.rb +498 -0
  124. data/lib/ruby_cms/settings.rb +145 -0
  125. data/lib/ruby_cms/settings_registry.rb +289 -0
  126. data/lib/ruby_cms/version.rb +5 -0
  127. data/lib/ruby_cms.rb +195 -0
  128. data/lib/tasks/ruby_cms.rake +27 -0
  129. data/log/test.log +17875 -0
  130. data/sig/ruby_cms.rbs +4 -0
  131. metadata +223 -0
@@ -0,0 +1,82 @@
1
+ <%# Single admin layout for app and RubyCMS. Uses RubyCMS sidebar, flash, content_for. %>
2
+ <!DOCTYPE html>
3
+ <html lang="<%= I18n.locale %>">
4
+ <head>
5
+ <title><%= content_for(:title) || "Admin Panel" %></title>
6
+ <meta name="viewport" content="width=device-width,initial-scale=1">
7
+ <meta name="rails-env" content="<%= Rails.env %>">
8
+ <%= csrf_meta_tags %>
9
+ <%= csp_meta_tag %>
10
+
11
+ <%= favicon_link_tag asset_path("favicon.ico") rescue nil %>
12
+ <link rel="icon" type="image/png" sizes="32x32" href="<%= asset_path("favicon-32x32.png") %>">
13
+ <link rel="icon" type="image/png" sizes="16x16" href="<%= asset_path("favicon-16x16.png") %>">
14
+ <link rel="apple-touch-icon" href="<%= asset_path("apple-touch-icon.png") %>">
15
+ <link rel="manifest" href="<%= asset_path("site.webmanifest") %>">
16
+
17
+ <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
18
+ <%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %>
19
+ <%= stylesheet_link_tag "actiontext", "data-turbo-track": "reload" rescue nil %>
20
+ <%= javascript_importmap_tags "admin" %>
21
+ </head>
22
+ <body class="min-h-screen bg-gray-50 text-gray-900">
23
+ <div class="flex h-screen overflow-hidden" data-controller="ruby-cms--mobile-menu">
24
+ <%# Mobile menu toggle (visible on md and below) %>
25
+ <button type="button" class="md:hidden fixed top-4 left-4 z-50 inline-flex items-center justify-center p-2 bg-white border border-gray-200/80 rounded-lg shadow-sm cursor-pointer" aria-label="Toggle menu" data-action="click->ruby-cms--mobile-menu#toggle">
26
+ <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
27
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
28
+ </svg>
29
+ </button>
30
+
31
+ <%= render "layouts/ruby_cms/admin_sidebar" %>
32
+
33
+ <%# Mobile overlay (JS adds class "show") %>
34
+ <div class="hidden fixed inset-0 bg-black/50 z-40 lg:hidden" data-ruby-cms--mobile-menu-target="overlay" data-action="click->ruby-cms--mobile-menu#close"></div>
35
+
36
+ <main class="flex-1 flex flex-col overflow-hidden bg-gray-50 min-w-0" role="main">
37
+ <% if content_for?(:full_width) %>
38
+ <%= yield %>
39
+ <% else %>
40
+ <div class="flex-1 flex flex-col min-h-0 p-6 overflow-y-auto bg-gray-50">
41
+ <div class="mx-auto w-full max-w-7xl">
42
+ <% if content_for?(:title) || content_for?(:header_actions) %>
43
+ <header class="flex-shrink-0 mb-4 <%= yield(:header_styles) if content_for?(:header_styles) %>">
44
+ <div class="flex flex-wrap items-center justify-between gap-4">
45
+ <div>
46
+ <% if content_for?(:title) %>
47
+ <h1 class="text-lg font-semibold tracking-tight text-gray-900"><%= yield :title %></h1>
48
+ <% end %>
49
+ <% if content_for?(:breadcrumbs) %>
50
+ <nav class="mt-0.5 text-sm text-gray-500" aria-label="Breadcrumb">
51
+ <ol class="flex items-center flex-wrap gap-x-2 gap-y-1">
52
+ <%= yield :breadcrumbs %>
53
+ </ol>
54
+ </nav>
55
+ <% end %>
56
+ </div>
57
+ <% if content_for?(:header_actions) %>
58
+ <div class="flex items-center flex-shrink-0">
59
+ <%= yield :header_actions %>
60
+ </div>
61
+ <% end %>
62
+ </div>
63
+ </header>
64
+ <% elsif content_for?(:breadcrumbs) %>
65
+ <nav class="mb-4" aria-label="Breadcrumb">
66
+ <ol class="flex items-center space-x-2 text-sm text-gray-600">
67
+ <%= content_for :breadcrumbs %>
68
+ </ol>
69
+ </nav>
70
+ <% end %>
71
+
72
+ <%= render "layouts/ruby_cms/admin_flash_messages" %>
73
+
74
+ <%= yield %>
75
+ </div>
76
+ </div>
77
+ <% end %>
78
+ </main>
79
+ </div>
80
+ </body>
81
+ </html>
82
+
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ # RubyCMS configuration
4
+
5
+ # -----------------------------------------------------------------------------
6
+ # Session / cookie hardening (recommended for /admin)
7
+ # Apply in config/initializers/session_store.rb or your session config:
8
+ #
9
+ # Rails.application.config.session_store :cookie_store,
10
+ # key: "_session",
11
+ # httponly: true,
12
+ # same_site: :lax, # or :strict
13
+ # secure: Rails.env.production?,
14
+ # expire_after: 2.weeks
15
+ #
16
+ # In your auth flow: rotate session on sign-in (e.g. Session.find_by(...)&.destroy
17
+ # before creating a new one). Use safe return-to: validate redirect paths to same
18
+ # origin or allowlist; avoid open redirects.
19
+ # -----------------------------------------------------------------------------
20
+
21
+ # -----------------------------------------------------------------------------
22
+ # CSP (Content Security Policy): set sensible defaults; document overrides.
23
+ # Example in config/initializers/content_security_policy.rb:
24
+ #
25
+ # Rails.application.config.content_security_policy do |p|
26
+ # p.default_src :self; p.script_src :self; p.style_src :self, :unsafe_inline
27
+ # end
28
+ # -----------------------------------------------------------------------------
29
+
30
+ RubyCms.configure do |c|
31
+ # Controller from which all /admin controllers inherit.
32
+ # Must provide current_user and run require_authentication.
33
+ # c.admin_base_controller = "ApplicationController"
34
+
35
+ # User model class name for permissions. Change if your app uses a different name.
36
+ # c.user_class_name = "User"
37
+
38
+ # When no Permission records exist, allow user.admin? to access /admin (bootstrap).
39
+ # Set to false to require permissions from the start.
40
+ # c.bootstrap_admin_with_role = true
41
+
42
+ # Redirect path when unauthenticated or not permitted (default: "/").
43
+ # Use "/session/new" to send to sign-in.
44
+ # c.unauthorized_redirect_path = "/"
45
+
46
+ # Visual editor: allowlist of page_key => template path.
47
+ # c.preview_templates = { "home" => "pages/home", "about" => "pages/about" }
48
+
49
+ # Preview data proc to pass instance variables.
50
+ # c.preview_data = ->(page_key, view) { { products: Product.limit(5) } }
51
+
52
+ # Optional hook: customize Ahoy visit scope (e.g. exclude internal traffic)
53
+ # c.analytics_visit_scope = ->(scope) { scope.where.not(ip: ["127.0.0.1"]) }
54
+
55
+ # Optional hook: customize Ahoy event scope
56
+ # c.analytics_event_scope = ->(scope) { scope }
57
+
58
+ # Optional hook: provide extra dashboard cards
59
+ # c.analytics_extra_cards = lambda do |start_date:, end_date:, period:, visits_scope:, events_scope:|
60
+ # [{ title: "Custom KPI", value: visits_scope.where.not(utm_source: nil).count }]
61
+ # end
62
+
63
+ # -----------------------------------------------------------------------------
64
+ # Optional bootstrap values (initializer -> DB import, once)
65
+ #
66
+ # On first boot/install, RubyCMS imports matching keys from config.ruby_cms into
67
+ # ruby_cms_preferences. After that, DB settings are source of truth.
68
+ #
69
+ # Example bootstrap values:
70
+ # c.analytics_default_period = "week"
71
+ # c.analytics_max_date_range_days = 365
72
+ # c.analytics_cache_duration_seconds = 600
73
+ # c.analytics_max_popular_pages = 10
74
+ # c.analytics_max_top_visitors = 10
75
+ # c.analytics_high_volume_threshold = 1000
76
+ # c.analytics_rapid_request_threshold = 50
77
+ # c.pagination_min_per_page = 5
78
+ # c.pagination_max_per_page = 200
79
+ # c.reserved_key_prefixes = %w[admin_]
80
+ # c.image_content_types = %w[image/png image/jpeg image/gif image/webp]
81
+ # c.image_max_size = 5 * 1024 * 1024
82
+ #
83
+ # To re-run manually:
84
+ # rails ruby_cms:import_initializer_settings
85
+ # -----------------------------------------------------------------------------
86
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCms
4
+ # App Integration: Connect CMS to host app features
5
+ module AppIntegration
6
+ # Link App: Connect CMS pages to app routes/controllers
7
+ module LinkApp
8
+ # Register an app route that can be linked from CMS
9
+ # @param key [String] Unique identifier for the route
10
+ # @param route_name [String] Route helper name (e.g. "products_path", "user_dashboard_path")
11
+ # @param label [String] Display label
12
+ # @param description [String] Optional description
13
+ # @param params [Hash] Default route parameters
14
+ def self.register_route(key:, route_name:, label:, description: nil, params: {})
15
+ @registered_routes ||= {}
16
+ @registered_routes[key] = {
17
+ route_name:,
18
+ label:,
19
+ description:,
20
+ params:
21
+ }
22
+ end
23
+
24
+ # Get all registered routes
25
+ def self.registered_routes
26
+ @registered_routes || {}
27
+ end
28
+
29
+ # Get a route by key
30
+ def self.get_route(key)
31
+ registered_routes[key.to_s]
32
+ end
33
+ end
34
+
35
+ # App Settings: Load app-specific settings/config into CMS context
36
+ module AppSettings
37
+ # Register a setting that can be loaded into CMS context
38
+ # @param key [String] Setting key
39
+ # @param loader [Proc] Callable that returns the setting value: ->(view_context) { ... }
40
+ def self.register_setting(key:, loader:)
41
+ @registered_settings ||= {}
42
+ @registered_settings[key.to_s] = loader
43
+ end
44
+
45
+ # Get all settings for a view context
46
+ def self.load_settings(view_context)
47
+ (@registered_settings || {}).transform_values do |loader|
48
+ loader.call(view_context)
49
+ end
50
+ end
51
+
52
+ # Get a specific setting
53
+ def self.get_setting(key, view_context)
54
+ loader = (@registered_settings || {})[key.to_s]
55
+ loader&.call(view_context)
56
+ end
57
+ end
58
+ end
59
+
60
+ # Convenience methods
61
+ class << self
62
+ # Register an app route
63
+ def register_app_route(**)
64
+ AppIntegration::LinkApp.register_route(**)
65
+ end
66
+
67
+ # Get registered routes
68
+ def app_routes
69
+ AppIntegration::LinkApp.registered_routes
70
+ end
71
+
72
+ # Register an app setting
73
+ def register_app_setting(**)
74
+ AppIntegration::AppSettings.register_setting(**)
75
+ end
76
+
77
+ # Load app settings for view context
78
+ def load_app_settings(view_context)
79
+ AppIntegration::AppSettings.load_settings(view_context)
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module RubyCms
6
+ class CLI < Thor
7
+ default_task :setup_admin
8
+
9
+ desc "setup_admin",
10
+ "Interactively create or select the first admin user and grant full permissions"
11
+ def setup_admin
12
+ RunSetupAdmin.call(shell:)
13
+ end
14
+
15
+ private
16
+
17
+ def shell
18
+ @shell ||= Thor::Shell::Basic.new
19
+ end
20
+ end
21
+
22
+ # Logic for the interactive first-admin setup. Uses Thor::Shell for prompts.
23
+ class RunSetupAdmin
24
+ ADMIN_PERMISSION_KEYS = %w[
25
+ manage_admin manage_permissions manage_content_blocks manage_visitor_errors
26
+ ].freeze
27
+
28
+ class << self
29
+ def call(shell: Thor::Shell::Basic.new)
30
+ new(shell:).call
31
+ end
32
+ end
33
+
34
+ def initialize(shell:)
35
+ @shell = shell
36
+ end
37
+
38
+ def call
39
+ RubyCms::Permission.ensure_defaults!
40
+ user_class = Rails.application.config.ruby_cms.user_class_name.constantize
41
+ email_attr = user_class.column_names.include?("email_address") ? :email_address : :email
42
+
43
+ user = choose_or_create_user(user_class, email_attr)
44
+ return if user.nil?
45
+
46
+ grant_permissions(user)
47
+ notify_success(user, email_attr)
48
+ end
49
+
50
+ private
51
+
52
+ def notify_success(user, email_attr)
53
+ @shell.say(
54
+ "\nDone. #{user.public_send(email_attr)}
55
+ now has: #{ADMIN_PERMISSION_KEYS.join(', ')}.", :green
56
+ )
57
+ @shell.say("Visit /admin to sign in.", :green)
58
+ end
59
+
60
+ def choose_or_create_user(user_class, email_attr)
61
+ users = fetch_recent_users(user_class)
62
+
63
+ if users.any?
64
+ prompt_existing_users(users, email_attr)
65
+ else
66
+ @shell.say("\nNo users yet. Create the first admin user.")
67
+ create_user(user_class, email_attr)
68
+ end
69
+ end
70
+
71
+ def fetch_recent_users(user_class)
72
+ user_class.order(id: :asc).limit(20).to_a
73
+ end
74
+
75
+ def prompt_existing_users(users, email_attr)
76
+ display_users(users, email_attr)
77
+ choice = ask_for_user_choice(users.size)
78
+
79
+ case choice
80
+ when "0"
81
+ find_or_create_by_email(users.first.class, email_attr)
82
+ else
83
+ select_user_or_fallback(users, choice.to_i, email_attr)
84
+ end
85
+ end
86
+
87
+ def display_users(users, email_attr)
88
+ @shell.say("\nExisting users:", :bold)
89
+ users.each_with_index do |u, i|
90
+ @shell.say(" #{i + 1}. #{u.public_send(email_attr)}")
91
+ end
92
+ @shell.say(" 0. Enter another email (or create new user)")
93
+ end
94
+
95
+ def ask_for_user_choice(max)
96
+ @shell.ask("Select (0–#{max}):", default: "1").to_s.strip
97
+ end
98
+
99
+ def select_user_or_fallback(users, index, email_attr)
100
+ if index.between?(1, users.size)
101
+ users[index - 1]
102
+ else
103
+ find_or_create_by_email(users.first.class, email_attr)
104
+ end
105
+ end
106
+
107
+ def find_or_create_by_email(user_class, email_attr)
108
+ email = @shell.ask("Email:").to_s.strip
109
+ return nil if email.blank?
110
+
111
+ user = user_class.find_by(email_attr => email)
112
+ if user
113
+ user
114
+ else
115
+ @shell.say("User not found.")
116
+ create = @shell.yes?("Create new user with this email?", default: true)
117
+ create ? create_user_with_email(user_class, email_attr, email) : nil
118
+ end
119
+ end
120
+
121
+ def create_user(user_class, email_attr)
122
+ email = @shell.ask("Email:").to_s.strip
123
+ return nil if email.blank?
124
+
125
+ create_user_with_email(user_class, email_attr, email)
126
+ end
127
+
128
+ def create_user_with_email(user_class, email_attr, email)
129
+ password, password_confirmation = ask_password_and_confirmation
130
+ return nil if password.blank? || password_confirmation.blank?
131
+
132
+ attrs = {
133
+ email_attr => email, password: password,
134
+ password_confirmation: password_confirmation
135
+ }
136
+ user_class.create!(attrs)
137
+ rescue ActiveRecord::RecordInvalid => e
138
+ @shell.say("Could not create user: #{e.record.errors.full_messages.to_sentence}", :red)
139
+ nil
140
+ end
141
+
142
+ def ask_password_and_confirmation
143
+ password = ask_password("Password:")
144
+ return [nil, nil] if password.blank?
145
+
146
+ password_confirmation = ask_password("Password (again):")
147
+ return [password, password_confirmation] unless password != password_confirmation
148
+
149
+ @shell.say("Passwords did not match. Aborted.", :red)
150
+ [nil, nil]
151
+ end
152
+
153
+ def ask_password(prompt)
154
+ if $stdin.respond_to?(:noecho) && $stdin.tty?
155
+ @shell.say("#{prompt} (hidden)")
156
+ $stdin.noecho { $stdin.gets&.chomp }.to_s
157
+ else
158
+ @shell.ask("#{prompt}:").to_s.strip
159
+ end
160
+ end
161
+
162
+ def grant_permissions(user)
163
+ ADMIN_PERMISSION_KEYS.each do |key|
164
+ perm = RubyCms::Permission.find_by!(key:)
165
+ RubyCms::UserPermission.find_or_create_by!(user: user, permission: perm)
166
+ end
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCms
4
+ module ContentBlocksGrouping
5
+ Row = ::Struct.new(:key, :id, :locale, :title, :content_type, :published, :content_block) do
6
+ def published?
7
+ published
8
+ end
9
+
10
+ def model_name # rubocop:disable Rails/Delegate
11
+ ::ContentBlock.model_name
12
+ end
13
+ end
14
+
15
+ class << self
16
+ # rubocop:disable Rails/Blank -- use plain Ruby for standalone unit specs without Rails
17
+ def group_by_key(collection)
18
+ return [] if collection.nil? || collection.empty?
19
+
20
+ collection
21
+ .group_by(&:key)
22
+ .map {|key, blocks| build_row(key, blocks) }
23
+ .sort_by(&:key)
24
+ end
25
+ # rubocop:enable Rails/Blank
26
+
27
+ def build_row(key, blocks)
28
+ first = blocks.first
29
+ Row.new(
30
+ key: key,
31
+ id: first.id,
32
+ locale: blocks.map(&:locale).sort.join(", "),
33
+ title: first.title,
34
+ content_type: first.content_type,
35
+ published: blocks.all?(&:published?),
36
+ content_block: first
37
+ )
38
+ end
39
+ end
40
+ end
41
+ end