panda-core 0.11.0 → 0.12.5

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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +15 -0
  3. data/app/assets/tailwind/application.css +196 -109
  4. data/app/builders/panda/core/form_builder.rb +9 -9
  5. data/app/components/panda/core/UI/button.rb +4 -4
  6. data/app/components/panda/core/admin/button_component.rb +2 -2
  7. data/app/components/panda/core/admin/form_input_component.rb +2 -2
  8. data/app/components/panda/core/admin/form_select_component.rb +2 -2
  9. data/app/components/panda/core/admin/panel_component.rb +1 -1
  10. data/app/components/panda/core/admin/statistics_component.rb +3 -3
  11. data/app/components/panda/core/admin/tab_bar_component.rb +3 -3
  12. data/app/components/panda/core/admin/table_component.rb +3 -3
  13. data/app/controllers/panda/core/admin/sessions_controller.rb +4 -1
  14. data/app/javascript/panda/core/controllers/navigation_toggle_controller.js +1 -1
  15. data/app/models/panda/core/user.rb +23 -14
  16. data/app/views/layouts/panda/core/admin.html.erb +10 -1
  17. data/app/views/panda/core/admin/dashboard/_default_content.html.erb +3 -3
  18. data/app/views/panda/core/admin/sessions/new.html.erb +2 -3
  19. data/app/views/panda/core/admin/shared/_sidebar.html.erb +5 -5
  20. data/config/brakeman.ignore +51 -0
  21. data/db/migrate/20250809000001_create_panda_core_users.rb +1 -1
  22. data/db/migrate/20251203100000_rename_is_admin_to_admin_in_panda_core_users.rb +18 -0
  23. data/lib/panda/core/asset_loader.rb +1 -1
  24. data/lib/panda/core/configuration.rb +4 -0
  25. data/lib/panda/core/engine/admin_controller_config.rb +6 -2
  26. data/lib/panda/core/engine/autoload_config.rb +9 -10
  27. data/lib/panda/core/engine/omniauth_config.rb +92 -34
  28. data/lib/panda/core/engine/route_config.rb +37 -0
  29. data/lib/panda/core/engine.rb +46 -41
  30. data/lib/panda/core/middleware.rb +146 -0
  31. data/lib/panda/core/module_registry.rb +21 -13
  32. data/lib/panda/core/testing/rails_helper.rb +17 -8
  33. data/lib/panda/core/testing/support/authentication_helpers.rb +6 -6
  34. data/lib/panda/core/testing/support/authentication_test_helpers.rb +2 -12
  35. data/lib/panda/core/testing/support/system/browser_console_logger.rb +1 -2
  36. data/lib/panda/core/testing/support/system/chrome_path.rb +38 -0
  37. data/lib/panda/core/testing/support/system/cuprite_helpers.rb +79 -0
  38. data/lib/panda/core/testing/support/system/cuprite_setup.rb +61 -66
  39. data/lib/panda/core/testing/support/system/system_test_helpers.rb +11 -11
  40. data/lib/panda/core/version.rb +1 -1
  41. data/lib/panda/core.rb +11 -0
  42. data/lib/tasks/panda/core/users.rake +3 -3
  43. data/lib/tasks/panda/shared.rake +31 -5
  44. data/public/panda-core-assets/favicons/browserconfig.xml +1 -1
  45. data/public/panda-core-assets/favicons/site.webmanifest +1 -1
  46. data/public/panda-core-assets/panda-core-0.11.0.css +2 -0
  47. data/public/panda-core-assets/panda-core-0.12.2.css +2 -0
  48. data/public/panda-core-assets/panda-core-0.12.3.css +2 -0
  49. data/public/panda-core-assets/panda-core.css +2 -2
  50. metadata +10 -8
  51. data/lib/panda/core/engine/middleware_config.rb +0 -17
  52. data/lib/panda/core/testing/support/system/better_system_tests.rb +0 -180
  53. data/lib/panda/core/testing/support/system/capybara_config.rb +0 -64
  54. data/lib/panda/core/testing/support/system/ci_capybara_config.rb +0 -77
  55. data/public/panda-core-assets/panda-core-0.10.6.css +0 -2
  56. data/public/panda-core-assets/panda-core-0.10.7.css +0 -2
@@ -8,9 +8,9 @@ module Panda
8
8
  prop :value, _Nilable(_Union(String, Integer, Float))
9
9
 
10
10
  def view_template
11
- div(class: "overflow-hidden p-4 bg-gradient-to-br rounded-lg border-2 from-light/20 to-light border-mid") do
12
- dt(class: "text-base font-medium truncate text-dark") { @metric }
13
- dd(class: "mt-1 text-3xl font-medium tracking-tight text-dark") { @value }
11
+ div(class: "overflow-hidden p-4 bg-gradient-to-br rounded-lg border-2 from-primary-50/20 to-primary-50 border-primary-400") do
12
+ dt(class: "text-base font-medium truncate text-primary-900") { @metric }
13
+ dd(class: "mt-1 text-3xl font-medium tracking-tight text-primary-900") { @value }
14
14
  end
15
15
  end
16
16
  end
@@ -21,7 +21,7 @@ module Panda
21
21
  select(
22
22
  id: "tabs",
23
23
  name: "tabs",
24
- class: "block py-1.5 pr-10 pl-3 w-full text-gray-900 rounded-md border-0 ring-1 ring-inset focus:ring-2 focus:ring-inset ring-mid focus:border-panda-dark focus:ring-panda-dark"
24
+ class: "block py-1.5 pr-10 pl-3 w-full text-gray-900 rounded-md border-0 ring-1 ring-inset focus:ring-2 focus:ring-inset ring-primary-400 focus:border-primary-600 focus:ring-primary-600"
25
25
  ) do
26
26
  @tabs.each do |tab|
27
27
  option { tab[:name] }
@@ -46,7 +46,7 @@ module Panda
46
46
  def render_tab(tab, is_current = false)
47
47
  classes = "py-4 px-1 text-sm font-medium whitespace-nowrap border-b-2 "
48
48
  classes += if is_current || tab[:current]
49
- "border-panda-dark text-panda-dark"
49
+ "border-primary-600 text-primary-600"
50
50
  else
51
51
  "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
52
52
  end
@@ -66,7 +66,7 @@ module Panda
66
66
  end
67
67
 
68
68
  def render_view_button(type, selected: false)
69
- button_class = "p-1.5 text-gray-400 rounded-md focus:ring-2 focus:ring-inset focus:outline-none focus:ring-panda-dark"
69
+ button_class = "p-1.5 text-gray-400 rounded-md focus:ring-2 focus:ring-inset focus:outline-none focus:ring-primary-600"
70
70
  button_class += if selected
71
71
  " ml-0.5 bg-white shadow-sm"
72
72
  else
@@ -33,7 +33,7 @@ module Panda
33
33
  private
34
34
 
35
35
  def render_table_with_rows
36
- div(class: "table overflow-x-auto mb-12 w-full rounded-lg border border-dark", style: "table-layout: fixed;") do
36
+ div(class: "table overflow-x-auto mb-12 w-full rounded-lg border border-gray-700", style: "table-layout: fixed;") do
37
37
  render_header
38
38
  render_rows
39
39
  end
@@ -49,7 +49,7 @@ module Panda
49
49
 
50
50
  def render_header
51
51
  div(class: "table-header-group") do
52
- div(class: "table-row text-base font-medium text-white bg-dark") do
52
+ div(class: "table-row text-base font-medium text-white bg-gray-800") do
53
53
  @columns.each_with_index do |column, i|
54
54
  header_classes = "table-cell sticky top-0 z-10 p-4"
55
55
  header_classes += " rounded-tl-md" if i.zero?
@@ -70,7 +70,7 @@ module Panda
70
70
  data: {post_id: row.id}
71
71
  ) do
72
72
  @columns.each do |column|
73
- div(class: "table-cell py-5 px-3 h-20 text-sm align-middle whitespace-nowrap border-b border-dark/20") do
73
+ div(class: "table-cell py-5 px-3 h-20 text-sm align-middle whitespace-nowrap border-b border-gray-700/20") do
74
74
  # Capture the cell content by calling the block with the row
75
75
  render_cell_content(row, column.cell)
76
76
  end
@@ -10,7 +10,10 @@ module Panda
10
10
  skip_before_action :authenticate_admin_user!, only: [:new, :create, :destroy, :failure]
11
11
 
12
12
  def new
13
- @providers = Core.config.authentication_providers.keys
13
+ # Only show providers that have valid credentials configured
14
+ @providers = Core.config.authentication_providers.select do |_key, config|
15
+ config[:client_id].present? && config[:client_secret].present?
16
+ end.keys
14
17
  end
15
18
 
16
19
  def create
@@ -21,7 +21,7 @@ export default class extends Controller {
21
21
  connect() {
22
22
  // Ensure menu starts in correct state
23
23
  // Check if this menu should be expanded by default (if a child is active)
24
- const hasActiveChild = this.menuTarget.querySelector(".bg-mid")
24
+ const hasActiveChild = this.menuTarget.querySelector(".bg-primary-500")
25
25
  if (hasActiveChild) {
26
26
  this.expand()
27
27
  } else {
@@ -19,16 +19,15 @@ module Panda
19
19
 
20
20
  before_save :downcase_email
21
21
 
22
+ # Determine which column stores admin flag (supports legacy `admin` and new `is_admin`)
23
+ def self.admin_column
24
+ # Prefer canonical `admin` if available, otherwise fall back to legacy `is_admin`
25
+ @admin_column ||= column_names.include?("admin") ? "admin" : "is_admin"
26
+ end
27
+
22
28
  # Scopes
23
- # Support both 'admin' (newer) and 'is_admin' (older) column names
24
29
  scope :admins, -> {
25
- if column_names.include?("admin")
26
- where(admin: true)
27
- elsif column_names.include?("is_admin")
28
- where(is_admin: true)
29
- else
30
- none
31
- end
30
+ where(admin_column => true)
32
31
  }
33
32
 
34
33
  def self.find_or_create_from_auth_hash(auth_hash)
@@ -45,10 +44,10 @@ module Panda
45
44
  end
46
45
 
47
46
  attributes = {
48
- email: auth_hash.info.email.downcase,
49
- name: auth_hash.info.name || "Unknown User",
50
- image_url: auth_hash.info.image,
51
- is_admin: User.count.zero? # First user is admin
47
+ :email => auth_hash.info.email.downcase,
48
+ :name => auth_hash.info.name || "Unknown User",
49
+ :image_url => auth_hash.info.image,
50
+ admin_column => User.count.zero? # First user is admin
52
51
  }
53
52
 
54
53
  user = create!(attributes)
@@ -62,10 +61,20 @@ module Panda
62
61
  end
63
62
 
64
63
  # Admin status check
65
- # Note: Column is named 'admin' in newer schemas, 'is_admin' in older ones
66
64
  def admin?
67
- self[:admin] || self[:is_admin] || false
65
+ ActiveRecord::Type::Boolean.new.cast(admin)
66
+ end
67
+
68
+ # Support both legacy `admin` and new `is_admin` columns
69
+ def admin
70
+ self[self.class.admin_column]
71
+ end
72
+
73
+ def admin=(value)
74
+ self[self.class.admin_column] = ActiveRecord::Type::Boolean.new.cast(value)
68
75
  end
76
+ alias_method :is_admin, :admin
77
+ alias_method :is_admin=, :admin=
69
78
 
70
79
  def active_for_authentication?
71
80
  true
@@ -5,7 +5,7 @@
5
5
  <%= render "panda/core/admin/shared/sidebar" %>
6
6
  </div>
7
7
  </div>
8
- <div class="flex flex-col flex-1 mt-16 ml-0 lg:mt-0 lg:ml-72" id="panda-inner-container" <% if content_for :sidebar %> data-controller="toggle" data-action="keydown.esc->modal#close" tabindex="-1"<% end %>>
8
+ <div class="flex flex-col flex-1 mt-16 ml-0 lg:mt-0 lg:ml-72" id="panda-inner-container" <% if content_for :sidebar %> data-controller="toggle" data-action="keydown.esc@window->toggle#hide keydown.escape@window->toggle#hide" tabindex="-1"<% end %>>
9
9
  <section id="panda-main" class="flex flex-row h-full">
10
10
  <div class="flex-1 h-full" id="panda-primary-content">
11
11
  <%= render "panda/core/admin/shared/breadcrumbs" %>
@@ -60,6 +60,15 @@
60
60
  <% end %>
61
61
  </div>
62
62
  </div>
63
+ <script>
64
+ window.addEventListener('keydown', function(e) {
65
+ if (e.key === 'Escape') {
66
+ document.querySelectorAll('[data-toggle-target="toggleable"]').forEach(function(el) {
67
+ el.classList.add('hidden');
68
+ });
69
+ }
70
+ });
71
+ </script>
63
72
  <% end %>
64
73
  </section>
65
74
  </div>
@@ -15,7 +15,7 @@
15
15
  <div class="ml-5 w-0 flex-1">
16
16
  <dt class="text-sm font-medium text-gray-500 truncate">Content Management</dt>
17
17
  <dd class="mt-1 text-lg font-semibold text-gray-900">
18
- <%= link_to "Manage CMS", panda_cms.admin_cms_dashboard_path, class: "text-indigo-600 hover:text-indigo-900" %>
18
+ <%= link_to "Manage CMS", panda_cms.admin_cms_dashboard_path, class: "text-primary-600 hover:text-primary-800" %>
19
19
  </dd>
20
20
  </div>
21
21
  </div>
@@ -35,7 +35,7 @@
35
35
  <div class="ml-5 w-0 flex-1">
36
36
  <dt class="text-sm font-medium text-gray-500 truncate">My Profile</dt>
37
37
  <dd class="mt-1 text-lg font-semibold text-gray-900">
38
- <%= link_to "Edit Profile", edit_admin_my_profile_path, class: "text-indigo-600 hover:text-indigo-900" %>
38
+ <%= link_to "Edit Profile", edit_admin_my_profile_path, class: "text-primary-600 hover:text-primary-800" %>
39
39
  </dd>
40
40
  </div>
41
41
  </div>
@@ -58,7 +58,7 @@
58
58
  <div class="ml-5 w-0 flex-1">
59
59
  <dt class="text-sm font-medium text-gray-500 truncate"><%= card[:title] %></dt>
60
60
  <dd class="mt-1 text-lg font-semibold text-gray-900">
61
- <%= link_to card[:link_text], card[:path], class: "text-indigo-600 hover:text-indigo-900" %>
61
+ <%= link_to card[:link_text], card[:path], class: "text-primary-600 hover:text-primary-800" %>
62
62
  </dd>
63
63
  </div>
64
64
  </div>
@@ -7,9 +7,8 @@
7
7
  <%= Panda::Core.config.login_page_title || "Sign in to your account" %>
8
8
  </h2>
9
9
  </div>
10
- <% if @providers&.any? || Panda::Core.config.authentication_providers.any? %>
11
- <% providers = @providers || Panda::Core.config.authentication_providers.keys %>
12
- <% providers.each do |provider| %>
10
+ <% if @providers&.any? %>
11
+ <% @providers.each do |provider| %>
13
12
  <% provider_config = Panda::Core.config.authentication_providers[provider] %>
14
13
  <% provider_path = provider_config&.dig(:path_name) || provider %>
15
14
  <div class="mt-4 text-center sm:mx-auto sm:w-full sm:max-w-sm">
@@ -42,7 +42,7 @@
42
42
  data-action="click->navigation-toggle#toggle"
43
43
  aria-controls="sub-menu-<%= index %>"
44
44
  aria-expanded="false"
45
- class="<%= is_active ? 'bg-mid text-white' : 'text-white hover:bg-mid/60' %> transition-all group flex items-center w-full gap-x-3 py-3 px-2 mb-2 rounded-md text-base leading-6 font-normal">
45
+ class="<%= is_active ? 'bg-primary-500 text-white' : 'text-white hover:bg-primary-500/60' %> transition-all group flex items-center w-full gap-x-3 py-3 px-2 mb-2 rounded-md text-base leading-6 font-normal">
46
46
  <span class="text-center w-6"><i class="<%= item[:icon] %> text-xl fa-fw"></i></span>
47
47
  <span class="flex-1 text-left"><%= item[:label] %></span>
48
48
  <i class="fa-solid fa-chevron-right text-xs transition-transform duration-150 ease-in-out"
@@ -56,14 +56,14 @@
56
56
  <%
57
57
  child_is_active = child[:path] && (request.path == child[:path] || request.path.starts_with?(child[:path] + "/"))
58
58
  %>
59
- <%= link_to child[:path], class: "#{child_is_active ? 'bg-mid text-white' : 'text-white hover:bg-mid/60'} group flex items-center w-full py-2 pr-2 pl-11 rounded-md text-sm font-normal transition-all" do %>
59
+ <%= link_to child[:path], class: "#{child_is_active ? 'bg-primary-500 text-white' : 'text-white hover:bg-primary-500/60'} group flex items-center w-full py-2 pr-2 pl-11 rounded-md text-sm font-normal transition-all" do %>
60
60
  <%= child[:label] %>
61
61
  <% end %>
62
62
  <% end %>
63
63
  </div>
64
64
  </div>
65
65
  <% else %>
66
- <%= link_to item[:path], class: "#{is_active ? "bg-mid text-white relative flex items-center transition-all py-3 px-2 mb-2 rounded-md group gap-x-3 text-base leading-6 font-normal" : "text-white hover:bg-mid/60 transition-all group flex items-center gap-x-3 py-3 px-2 mb-2 rounded-md text-base leading-6 font-normal"}" do %>
66
+ <%= link_to item[:path], class: "#{is_active ? "bg-primary-500 text-white relative flex items-center transition-all py-3 px-2 mb-2 rounded-md group gap-x-3 text-base leading-6 font-normal" : "text-white hover:bg-primary-500/60 transition-all group flex items-center gap-x-3 py-3 px-2 mb-2 rounded-md text-base leading-6 font-normal"}" do %>
67
67
  <span class="text-center w-6"><i class="<%= item[:icon] %> text-xl fa-fw"></i></span>
68
68
  <span><%= item[:label] %></span>
69
69
  <% end %>
@@ -71,7 +71,7 @@
71
71
  </li>
72
72
  <% end %>
73
73
  <li>
74
- <%= button_to panda_core.admin_logout_path, method: :delete, id: "logout-link", data: { turbo: false }, class: "text-white hover:bg-mid/60 transition-all group flex items-center gap-x-3 py-3 px-2 mb-2 rounded-md text-base leading-6 font-normal w-full" do %>
74
+ <%= button_to panda_core.admin_logout_path, method: :delete, id: "logout-link", data: { turbo: false }, class: "text-white hover:bg-primary-500/60 transition-all group flex items-center gap-x-3 py-3 px-2 mb-2 rounded-md text-base leading-6 font-normal w-full" do %>
75
75
  <span class="text-center w-6"><i class="text-xl fa-solid fa-door-open fa-fw"></i></span>
76
76
  <span>Logout</span>
77
77
  <% end %>
@@ -81,7 +81,7 @@
81
81
  # Check if we're in the my_profile section
82
82
  is_my_profile_active = request.path.starts_with?("#{Panda::Core.config.admin_path}/my_profile")
83
83
  %>
84
- <%= link_to panda_core.admin_my_profile_path, class: "#{is_my_profile_active ? 'bg-mid text-white' : 'text-white hover:bg-mid/60'} transition-all group flex items-center gap-x-3 py-3 px-2 mb-2 rounded-md text-base leading-6 font-normal w-full", title: "My Profile" do %>
84
+ <%= link_to panda_core.admin_my_profile_path, class: "#{is_my_profile_active ? 'bg-primary-500 text-white' : 'text-white hover:bg-primary-500/60'} transition-all group flex items-center gap-x-3 py-3 px-2 mb-2 rounded-md text-base leading-6 font-normal w-full", title: "My Profile" do %>
85
85
  <% if current_user.avatar.attached? %>
86
86
  <span class="text-center w-6"><%= image_tag main_app.url_for(current_user.avatar), alt: current_user.name, class: "w-auto h-7 rounded-full object-cover" %></span>
87
87
  <% elsif !current_user.image_url.to_s.empty? %>
@@ -0,0 +1,51 @@
1
+ {
2
+ "ignored_warnings": [
3
+ {
4
+ "warning_type": "Command Injection",
5
+ "warning_code": 14,
6
+ "fingerprint": "29d5b60b583fc98e293f6ac47f153bbf5db48a03963f8c9a7711bd251ecf2d81",
7
+ "check_name": "Execute",
8
+ "message": "Possible command injection",
9
+ "file": "lib/panda/core/testing/support/system/cuprite_helpers.rb",
10
+ "line": 78,
11
+ "link": "https://brakemanscanner.org/docs/warning_types/command_injection/",
12
+ "code": "system(\"ffmpeg -y -framerate 8 -pattern_type glob -i '#{File.join(dir, \"frames\")}/*.png' -c:v libx264 -pix_fmt yuv420p '#{capybara_artifacts_dir.join(\"#{example.metadata[:full_description].parameterize}.mp4\")}'\\n\")",
13
+ "render_path": null,
14
+ "location": {
15
+ "type": "method",
16
+ "class": "Panda::Core::Testing::CupriteHelpers",
17
+ "method": "record_video!"
18
+ },
19
+ "user_input": "File.join(dir, \"frames\")",
20
+ "confidence": "Medium",
21
+ "cwe_id": [
22
+ 77
23
+ ],
24
+ "note": "This does not use user input and is based on environment variables and the current folder"
25
+ },
26
+ {
27
+ "warning_type": "Command Injection",
28
+ "warning_code": 14,
29
+ "fingerprint": "d3c339f054cbd511956e04eaf8763bc722bd2e4559a1c1fec58749abef9792cd",
30
+ "check_name": "Execute",
31
+ "message": "Possible command injection",
32
+ "file": "lib/panda/core/testing/support/system/chrome_verification.rb",
33
+ "line": 43,
34
+ "link": "https://brakemanscanner.org/docs/warning_types/command_injection/",
35
+ "code": "Process.spawn(*[BrowserPath.resolve, v.nil? ? (\"--#{k}\") : (\"--#{k}=#{v}\"), \"about:blank\"], :out => (File::NULL), :err => (File::NULL))",
36
+ "render_path": null,
37
+ "location": {
38
+ "type": "method",
39
+ "class": "Panda::Core::Testing::Support::System::ChromeVerification",
40
+ "method": "s(:self).verify!"
41
+ },
42
+ "user_input": "k",
43
+ "confidence": "Medium",
44
+ "cwe_id": [
45
+ 77
46
+ ],
47
+ "note": "Chrome verification uses a fixed set of browser flags from CHROME_FLAGS constant; no user input is accepted"
48
+ }
49
+ ],
50
+ "brakeman_version": "7.1.1"
51
+ }
@@ -11,7 +11,7 @@ class CreatePandaCoreUsers < ActiveRecord::Migration[7.1]
11
11
  t.string :name
12
12
  t.string :email, null: false
13
13
  t.string :image_url
14
- t.boolean :is_admin, default: false, null: false
14
+ t.boolean :admin, default: false, null: false
15
15
  t.string :current_theme
16
16
  t.string :oauth_avatar_url
17
17
  t.timestamps
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RenameIsAdminToAdminInPandaCoreUsers < ActiveRecord::Migration[7.1]
4
+ def up
5
+ # If apps created an `is_admin` column during the brief rename, move it back
6
+ return unless column_exists?(:panda_core_users, :is_admin)
7
+ return if column_exists?(:panda_core_users, :admin)
8
+
9
+ rename_column :panda_core_users, :is_admin, :admin
10
+ end
11
+
12
+ def down
13
+ return unless column_exists?(:panda_core_users, :admin)
14
+ return if column_exists?(:panda_core_users, :is_admin)
15
+
16
+ rename_column :panda_core_users, :admin, :is_admin
17
+ end
18
+ end
@@ -45,7 +45,7 @@ module Panda
45
45
 
46
46
  # In production, prefer local compiled assets over GitHub
47
47
  # Only use GitHub assets when explicitly enabled or when local assets aren't available
48
- if Rails.env.production?
48
+ if Rails.env.production? || Rails.env.staging?
49
49
  # Check if compiled assets exist locally
50
50
  return false if compiled_assets_available?
51
51
 
@@ -1,3 +1,5 @@
1
+ require "active_support/core_ext/numeric/bytes"
2
+
1
3
  module Panda
2
4
  module Core
3
5
  class Configuration
@@ -7,6 +9,7 @@ module Panda
7
9
  :cache_store,
8
10
  :parent_controller,
9
11
  :parent_mailer,
12
+ :auto_mount_engine,
10
13
  :mailer_sender,
11
14
  :mailer_default_url_options,
12
15
  :session_token_cookie,
@@ -38,6 +41,7 @@ module Panda
38
41
  @cache_store = :memory_store
39
42
  @parent_controller = "ActionController::API"
40
43
  @parent_mailer = "ActionMailer::Base"
44
+ @auto_mount_engine = true
41
45
  @mailer_sender = "support@example.com"
42
46
  @mailer_default_url_options = {host: "localhost:3000"}
43
47
  @session_token_cookie = :panda_session
@@ -10,9 +10,13 @@ module Panda
10
10
  included do
11
11
  # Create AdminController alias after controllers are loaded
12
12
  # This allows other gems to inherit from Panda::Core::AdminController
13
- initializer "panda_core.admin_controller_alias", after: :load_config_initializers do
13
+ config.to_prepare do
14
+ # Use on_load to ensure ActionController is available
14
15
  ActiveSupport.on_load(:action_controller_base) do
15
- Panda::Core.const_set(:AdminController, Panda::Core::Admin::BaseController) unless Panda::Core.const_defined?(:AdminController)
16
+ # Create the alias if it doesn't exist
17
+ unless Panda::Core.const_defined?(:AdminController)
18
+ Panda::Core.const_set(:AdminController, Panda::Core::Admin::BaseController)
19
+ end
16
20
  end
17
21
  end
18
22
  end
@@ -3,21 +3,20 @@
3
3
  module Panda
4
4
  module Core
5
5
  class Engine < ::Rails::Engine
6
- # Autoload paths configuration
7
6
  module AutoloadConfig
8
7
  extend ActiveSupport::Concern
9
8
 
10
9
  included do
11
- config.eager_load_namespaces << Panda::Core::Engine
10
+ # These must run BEFORE initialization, so this is allowed
11
+ config.autoload_paths << root.join("app/builders")
12
+ config.autoload_paths << root.join("app/components")
13
+ config.autoload_paths << root.join("app/services")
14
+ config.autoload_paths << root.join("app/models")
15
+ config.autoload_paths << root.join("app/helpers")
16
+ config.autoload_paths << root.join("app/constraints")
12
17
 
13
- # Add engine's app directories to autoload paths
14
- # Note: Only add the root directories, not nested subdirectories
15
- # Zeitwerk will automatically discover nested modules from these roots
16
- config.autoload_paths += Dir[root.join("app", "models")]
17
- config.autoload_paths += Dir[root.join("app", "controllers")]
18
- config.autoload_paths += Dir[root.join("app", "builders")]
19
- config.autoload_paths += Dir[root.join("app", "components")]
20
- config.autoload_paths += Dir[root.join("app", "services")]
18
+ # Mirror eager-load as needed
19
+ config.eager_load_paths.concat(config.autoload_paths)
21
20
  end
22
21
  end
23
22
  end
@@ -1,51 +1,109 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "active_support/concern"
4
+
3
5
  module Panda
4
6
  module Core
5
7
  class Engine < ::Rails::Engine
6
- # OmniAuth configuration
7
8
  module OmniauthConfig
8
9
  extend ActiveSupport::Concern
9
10
 
11
+ PROVIDER_REGISTRY = {
12
+ # Microsoft
13
+ "microsoft" => :microsoft_graph,
14
+ "microsoft_graph" => :microsoft_graph,
15
+
16
+ # Google
17
+ "google" => :google_oauth2,
18
+ "google_oauth2" => :google_oauth2,
19
+ "gmail" => :google_oauth2,
20
+
21
+ # GitHub
22
+ "github" => :github,
23
+ "gh" => :github,
24
+
25
+ # Developer
26
+ "developer" => :developer
27
+ }.freeze
28
+
10
29
  included do
11
- initializer "panda_core.omniauth" do |app|
12
- # Load OAuth provider gems
13
- require_relative "../oauth_providers"
14
- Panda::Core::OAuthProviders.setup
15
-
16
- # Mount OmniAuth at configurable admin path
17
- app.middleware.use OmniAuth::Builder do
18
- # Configure OmniAuth to use the configured admin path
19
- configure do |config|
20
- config.path_prefix = "#{Panda::Core.config.admin_path}/auth"
21
- # POST-only for CSRF protection (CVE-2015-9284)
22
- # All login forms use POST via form_tag method: "post"
23
- config.allowed_request_methods = [:post]
24
- end
30
+ if respond_to?(:initializer)
31
+ initializer "panda_core.omniauth" do |app|
32
+ require_relative "../oauth_providers"
33
+ Panda::Core::OAuthProviders.setup
25
34
 
26
- Panda::Core.config.authentication_providers.each do |provider_name, settings|
27
- # Build provider options, allowing custom path name override
28
- provider_options = settings[:options] || {}
29
-
30
- # If path_name is specified, use it to override the default strategy name in URLs
31
- if settings[:path_name].present?
32
- provider_options = provider_options.merge(name: settings[:path_name])
33
- end
34
-
35
- case provider_name.to_s
36
- when "microsoft_graph"
37
- provider :microsoft_graph, settings[:client_id], settings[:client_secret], provider_options
38
- when "google_oauth2"
39
- provider :google_oauth2, settings[:client_id], settings[:client_secret], provider_options
40
- when "github"
41
- provider :github, settings[:client_id], settings[:client_secret], provider_options
42
- when "developer"
43
- provider :developer if Rails.env.development?
44
- end
35
+ load_yaml_provider_overrides!
36
+ mount_omniauth_middleware(app)
37
+
38
+ # Configure OmniAuth globals AFTER all initializers have run
39
+ # This ensures Panda::Core.config.admin_path has been set by the app
40
+ app.config.after_initialize do
41
+ configure_omniauth_globals
45
42
  end
46
43
  end
47
44
  end
48
45
  end
46
+
47
+ private
48
+
49
+ # 1. YAML overrides
50
+ def load_yaml_provider_overrides!
51
+ path = Panda::Core::Engine.root.join("config/providers.yml")
52
+ return unless File.exist?(path)
53
+
54
+ yaml = YAML.load_file(path) || {}
55
+ (yaml["providers"] || {}).each do |name, settings|
56
+ Panda::Core.config.authentication_providers[name.to_s] ||= {}
57
+ Panda::Core.config.authentication_providers[name.to_s].deep_merge!(settings)
58
+ end
59
+ end
60
+
61
+ # 2. Global settings
62
+ def configure_omniauth_globals
63
+ OmniAuth.configure do |c|
64
+ c.allowed_request_methods = [:post]
65
+ c.path_prefix = "#{Panda::Core.config.admin_path}/auth"
66
+ end
67
+ end
68
+
69
+ # 3. Middleware insertion
70
+ def mount_omniauth_middleware(app)
71
+ ctx = self # Capture the Engine/Concern context
72
+
73
+ Panda::Core::Middleware.use(app, OmniAuth::Builder) do
74
+ Panda::Core.config.authentication_providers.each do |name, settings|
75
+ ctx.send(:configure_provider, self, name, settings)
76
+ end
77
+ end
78
+ end
79
+
80
+ # 4. Provider builder
81
+ def configure_provider(builder, name, settings)
82
+ symbol = PROVIDER_REGISTRY[name.to_s]
83
+
84
+ unless symbol
85
+ Rails.logger.warn("[panda-core] Unknown OmniAuth provider: #{name.inspect}")
86
+ return
87
+ end
88
+
89
+ return if symbol == :developer && !Rails.env.development?
90
+
91
+ # Skip providers without credentials (except developer which doesn't need them)
92
+ has_credentials = settings[:client_id].present? && settings[:client_secret].present?
93
+ if symbol != :developer && !has_credentials
94
+ Rails.logger.info("[panda-core] Skipping OmniAuth provider #{name.inspect}: missing client_id or client_secret")
95
+ return
96
+ end
97
+
98
+ options = (settings[:options] || {}).dup
99
+ options[:name] = settings[:path_name] if settings[:path_name].present?
100
+
101
+ if settings[:client_id] && settings[:client_secret]
102
+ builder.provider symbol, settings[:client_id], settings[:client_secret], options
103
+ else
104
+ builder.provider symbol, options
105
+ end
106
+ end
49
107
  end
50
108
  end
51
109
  end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ class Engine < ::Rails::Engine
6
+ # Automatically mount the engine into host applications
7
+ module RouteConfig
8
+ extend ActiveSupport::Concern
9
+
10
+ included do
11
+ # Append Core routes to the host app after initialization so
12
+ # engine routes are available without manual mounting.
13
+ config.after_initialize do |app|
14
+ next unless Panda::Core.config.auto_mount_engine
15
+
16
+ route_set = app.routes
17
+ already_mounted =
18
+ route_set.routes.any? do |route|
19
+ route.app == Panda::Core::Engine ||
20
+ (route.app.respond_to?(:app) && route.app.app == Panda::Core::Engine)
21
+ end
22
+ already_mounted ||= route_set.named_routes.key?(:panda_core)
23
+
24
+ next if already_mounted
25
+
26
+ route_set.append do
27
+ # Re-check inside the mapper to avoid duplicate mounts during reloads
28
+ next if route_set.named_routes.key?(:panda_core)
29
+
30
+ mount Panda::Core::Engine => "/", :as => "panda_core"
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end