panda-core 0.12.5 → 1.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 (251) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +1 -1
  3. data/README.md +53 -0
  4. data/app/assets/builds/panda_core_admin.css +2 -0
  5. data/app/assets/tailwind/application.css +236 -36
  6. data/app/builders/panda/core/form_builder.rb +120 -18
  7. data/app/components/panda/core/UI/badge.html.erb +10 -0
  8. data/app/components/panda/core/UI/badge.rb +14 -30
  9. data/app/components/panda/core/UI/button.html.erb +5 -0
  10. data/app/components/panda/core/UI/button.rb +11 -15
  11. data/app/components/panda/core/UI/card.html.erb +3 -0
  12. data/app/components/panda/core/UI/card.rb +7 -6
  13. data/app/components/panda/core/admin/attribute_diff_component.html.erb +21 -0
  14. data/app/components/panda/core/admin/attribute_diff_component.rb +29 -0
  15. data/app/components/panda/core/admin/breadcrumb_component.html.erb +36 -0
  16. data/app/components/panda/core/admin/breadcrumb_component.rb +12 -64
  17. data/app/components/panda/core/admin/breadcrumbs_component.html.erb +16 -0
  18. data/app/components/panda/core/admin/breadcrumbs_component.rb +26 -0
  19. data/app/components/panda/core/admin/button_component.html.erb +9 -0
  20. data/app/components/panda/core/admin/button_component.rb +36 -31
  21. data/app/components/panda/core/admin/callout_component.html.erb +17 -0
  22. data/app/components/panda/core/admin/callout_component.rb +57 -0
  23. data/app/components/panda/core/admin/code_block_component.html.erb +9 -0
  24. data/app/components/panda/core/admin/code_block_component.rb +23 -0
  25. data/app/components/panda/core/admin/container_component.html.erb +27 -0
  26. data/app/components/panda/core/admin/container_component.rb +11 -77
  27. data/app/components/panda/core/admin/contributors_summary_component.html.erb +15 -0
  28. data/app/components/panda/core/admin/contributors_summary_component.rb +23 -0
  29. data/app/components/panda/core/admin/dashboard_default_content_component.html.erb +70 -0
  30. data/app/components/panda/core/admin/dashboard_default_content_component.rb +32 -0
  31. data/app/components/panda/core/admin/delete_button_component.html.erb +4 -0
  32. data/app/components/panda/core/admin/delete_button_component.rb +41 -0
  33. data/app/components/panda/core/admin/dropdown_button_component.html.erb +28 -0
  34. data/app/components/panda/core/admin/dropdown_button_component.rb +100 -0
  35. data/app/components/panda/core/admin/dropdown_component.html.erb +32 -0
  36. data/app/components/panda/core/admin/dropdown_component.rb +65 -0
  37. data/app/components/panda/core/admin/empty_state_component.html.erb +16 -0
  38. data/app/components/panda/core/admin/empty_state_component.rb +25 -0
  39. data/app/components/panda/core/admin/file_gallery_component.html.erb +58 -0
  40. data/app/components/panda/core/admin/file_gallery_component.rb +26 -98
  41. data/app/components/panda/core/admin/filter_pills_component.html.erb +6 -0
  42. data/app/components/panda/core/admin/filter_pills_component.rb +29 -0
  43. data/app/components/panda/core/admin/flash_message_component.html.erb +11 -0
  44. data/app/components/panda/core/admin/flash_message_component.rb +33 -60
  45. data/app/components/panda/core/admin/form_error_component.html.erb +7 -0
  46. data/app/components/panda/core/admin/form_error_component.rb +8 -16
  47. data/app/components/panda/core/admin/form_footer_component.html.erb +16 -0
  48. data/app/components/panda/core/admin/form_footer_component.rb +65 -0
  49. data/app/components/panda/core/admin/form_input_component.html.erb +1 -0
  50. data/app/components/panda/core/admin/form_input_component.rb +21 -13
  51. data/app/components/panda/core/admin/form_section_component.html.erb +16 -0
  52. data/app/components/panda/core/admin/form_section_component.rb +64 -0
  53. data/app/components/panda/core/admin/form_select_component.html.erb +5 -0
  54. data/app/components/panda/core/admin/form_select_component.rb +25 -26
  55. data/app/components/panda/core/admin/heading_component.html.erb +26 -0
  56. data/app/components/panda/core/admin/heading_component.rb +23 -60
  57. data/app/components/panda/core/admin/login_form_component.html.erb +38 -0
  58. data/app/components/panda/core/admin/login_form_component.rb +28 -0
  59. data/app/components/panda/core/admin/main_layout_component.html.erb +88 -0
  60. data/app/components/panda/core/admin/main_layout_component.rb +19 -0
  61. data/app/components/panda/core/admin/my_profile/connected_account_component.html.erb +41 -0
  62. data/app/components/panda/core/admin/my_profile/connected_account_component.rb +27 -0
  63. data/app/components/panda/core/admin/navigation/item_component.html.erb +34 -0
  64. data/app/components/panda/core/admin/navigation/item_component.rb +69 -0
  65. data/app/components/panda/core/admin/navigation/sub_item_component.html.erb +9 -0
  66. data/app/components/panda/core/admin/navigation/sub_item_component.rb +44 -0
  67. data/app/components/panda/core/admin/navigation/user_menu_component.html.erb +30 -0
  68. data/app/components/panda/core/admin/navigation/user_menu_component.rb +36 -0
  69. data/app/components/panda/core/admin/page_header_component.html.erb +28 -0
  70. data/app/components/panda/core/admin/page_header_component.rb +19 -35
  71. data/app/components/panda/core/admin/pagination_component.html.erb +33 -0
  72. data/app/components/panda/core/admin/pagination_component.rb +56 -0
  73. data/app/components/panda/core/admin/panel_component.html.erb +19 -0
  74. data/app/components/panda/core/admin/panel_component.rb +4 -29
  75. data/app/components/panda/core/admin/prose_panel_component.html.erb +6 -0
  76. data/app/components/panda/core/admin/prose_panel_component.rb +43 -0
  77. data/app/components/panda/core/admin/search_bar_component.html.erb +19 -0
  78. data/app/components/panda/core/admin/search_bar_component.rb +31 -0
  79. data/app/components/panda/core/admin/search_filter_bar_component.html.erb +22 -0
  80. data/app/components/panda/core/admin/search_filter_bar_component.rb +27 -0
  81. data/app/components/panda/core/admin/secret_field_component.html.erb +24 -0
  82. data/app/components/panda/core/admin/secret_field_component.rb +31 -0
  83. data/app/components/panda/core/admin/sidebar_component.html.erb +160 -0
  84. data/app/components/panda/core/admin/sidebar_component.rb +55 -0
  85. data/app/components/panda/core/admin/slideover_component.html.erb +67 -0
  86. data/app/components/panda/core/admin/slideover_component.rb +31 -70
  87. data/app/components/panda/core/admin/statistics_component.html.erb +4 -0
  88. data/app/components/panda/core/admin/statistics_component.rb +6 -8
  89. data/app/components/panda/core/admin/tab_bar_component.html.erb +48 -0
  90. data/app/components/panda/core/admin/tab_bar_component.rb +32 -67
  91. data/app/components/panda/core/admin/table_component.html.erb +82 -0
  92. data/app/components/panda/core/admin/table_component.rb +54 -87
  93. data/app/components/panda/core/admin/tag_badge_component.html.erb +7 -0
  94. data/app/components/panda/core/admin/tag_badge_component.rb +21 -0
  95. data/app/components/panda/core/admin/tag_component.html.erb +1 -0
  96. data/app/components/panda/core/admin/tag_component.rb +51 -20
  97. data/app/components/panda/core/admin/tag_input_component.html.erb +37 -0
  98. data/app/components/panda/core/admin/tag_input_component.rb +22 -0
  99. data/app/components/panda/core/admin/tags_panel_component.html.erb +8 -0
  100. data/app/components/panda/core/admin/tags_panel_component.rb +33 -0
  101. data/app/components/panda/core/admin/user_activity_component.html.erb +15 -0
  102. data/app/components/panda/core/admin/user_activity_component.rb +9 -22
  103. data/app/components/panda/core/admin/user_display_component.html.erb +8 -0
  104. data/app/components/panda/core/admin/user_display_component.rb +19 -22
  105. data/app/components/panda/core/base.rb +35 -42
  106. data/app/components/panda/core/shared/footer_component.html.erb +2 -0
  107. data/app/components/panda/core/shared/footer_component.rb +11 -0
  108. data/app/components/panda/core/shared/header_component.html.erb +32 -0
  109. data/app/components/panda/core/shared/header_component.rb +19 -0
  110. data/app/constraints/panda/core/admin_constraint.rb +2 -2
  111. data/app/controllers/concerns/panda/core/admin_authorization.rb +58 -0
  112. data/app/controllers/concerns/panda/core/authorizable.rb +107 -0
  113. data/app/controllers/panda/core/admin/base_controller.rb +62 -3
  114. data/app/controllers/panda/core/admin/editor/search_controller.rb +17 -0
  115. data/app/controllers/panda/core/admin/feature_flags_controller.rb +35 -0
  116. data/app/controllers/panda/core/admin/file_categories_controller.rb +84 -0
  117. data/app/controllers/panda/core/admin/files_controller.rb +230 -0
  118. data/app/controllers/panda/core/admin/import_sessions_controller.rb +120 -0
  119. data/app/controllers/panda/core/admin/my_profile/logins_controller.rb +24 -0
  120. data/app/controllers/panda/core/admin/my_profile_controller.rb +31 -0
  121. data/app/controllers/panda/core/admin/search_controller.rb +15 -0
  122. data/app/controllers/panda/core/admin/sessions_controller.rb +84 -10
  123. data/app/controllers/panda/core/admin/tags_controller.rb +77 -0
  124. data/app/controllers/panda/core/admin/test_sessions_controller.rb +5 -6
  125. data/app/controllers/panda/core/admin/users_controller.rb +184 -0
  126. data/app/controllers/panda/core/application_controller.rb +13 -1
  127. data/app/helpers/panda/core/active_storage_helper.rb +24 -0
  128. data/app/helpers/panda/core/feature_flag_helper.rb +11 -0
  129. data/app/helpers/panda/core/form_helper.rb +15 -0
  130. data/app/javascript/panda/core/controllers/avatar_upload_controller.js +73 -0
  131. data/app/javascript/panda/core/controllers/clipboard_controller.js +119 -0
  132. data/app/javascript/panda/core/controllers/collapsible_item_controller.js +45 -0
  133. data/app/javascript/panda/core/controllers/custom_select_controller.js +256 -0
  134. data/app/javascript/panda/core/controllers/datepicker_controller.js +121 -0
  135. data/app/javascript/panda/core/controllers/daterange_controller.js +193 -0
  136. data/app/javascript/panda/core/controllers/file_gallery_controller.js +134 -0
  137. data/app/javascript/panda/core/controllers/file_upload_controller.js +165 -0
  138. data/app/javascript/panda/core/controllers/global_search_controller.js +184 -0
  139. data/app/javascript/panda/core/controllers/index.js +36 -0
  140. data/app/javascript/panda/core/controllers/mobile_sidebar_controller.js +58 -0
  141. data/app/javascript/panda/core/controllers/navigation_toggle_controller.js +49 -24
  142. data/app/javascript/panda/core/controllers/row_link_controller.js +27 -0
  143. data/app/javascript/panda/core/controllers/tag_input_controller.js +183 -0
  144. data/app/javascript/panda/core/tailwindcss-stimulus-components.js +2 -3
  145. data/app/javascript/panda/core/vendor/@fortawesome--fontawesome-free@7.2.0.js +8129 -0
  146. data/app/javascript/panda/core/vendor/@hotwired--turbo.js +7197 -100
  147. data/app/javascript/panda/core/vendor/@rails--actioncable--src.js +511 -2
  148. data/app/javascript/panda/core/vendor/@tailwindplus--elements@1.0.22.js +65 -0
  149. data/app/javascript/panda/core/vendor/cropperjs@2.1.0.js +3 -0
  150. data/app/javascript/panda/core/vendor/vanilla-calendar-pro.js +2 -0
  151. data/app/models/concerns/panda/core/has_metadata.rb +164 -0
  152. data/app/models/concerns/panda/core/importable.rb +79 -0
  153. data/app/models/concerns/panda/core/searchable.rb +66 -0
  154. data/app/models/concerns/panda/core/taggable.rb +37 -0
  155. data/app/models/panda/core/feature_flag.rb +56 -0
  156. data/app/models/panda/core/file_categorization.rb +14 -0
  157. data/app/models/panda/core/file_category.rb +45 -0
  158. data/app/models/panda/core/import_session.rb +79 -0
  159. data/app/models/panda/core/presence.rb +24 -0
  160. data/app/models/panda/core/tag.rb +33 -0
  161. data/app/models/panda/core/tagging.rb +14 -0
  162. data/app/models/panda/core/user.rb +94 -16
  163. data/app/models/panda/core/user_activity.rb +42 -0
  164. data/app/models/panda/core/user_session.rb +35 -0
  165. data/app/services/panda/core/attach_avatar_service.rb +15 -3
  166. data/app/services/panda/core/csv_import_service.rb +65 -0
  167. data/app/services/panda/core/file_categorizer.rb +52 -0
  168. data/app/services/panda/core/file_parser.rb +76 -0
  169. data/app/services/panda/core/invite_user_service.rb +44 -0
  170. data/app/services/panda/core/presence_service.rb +49 -0
  171. data/app/views/layouts/panda/core/admin.html.erb +6 -76
  172. data/app/views/layouts/panda/core/admin_simple.html.erb +2 -2
  173. data/app/views/panda/core/admin/dashboard/_default_content.html.erb +1 -73
  174. data/app/views/panda/core/admin/dashboard/show.html.erb +12 -15
  175. data/app/views/panda/core/admin/feature_flags/index.html.erb +56 -0
  176. data/app/views/panda/core/admin/file_categories/_form.html.erb +20 -0
  177. data/app/views/panda/core/admin/file_categories/edit.html.erb +5 -0
  178. data/app/views/panda/core/admin/file_categories/index.html.erb +55 -0
  179. data/app/views/panda/core/admin/file_categories/new.html.erb +5 -0
  180. data/app/views/panda/core/admin/files/_file_details.html.erb +156 -0
  181. data/app/views/panda/core/admin/files/index.html.erb +61 -0
  182. data/app/views/panda/core/admin/import_sessions/column_map.html.erb +43 -0
  183. data/app/views/panda/core/admin/import_sessions/index.html.erb +48 -0
  184. data/app/views/panda/core/admin/import_sessions/new.html.erb +37 -0
  185. data/app/views/panda/core/admin/import_sessions/preview.html.erb +51 -0
  186. data/app/views/panda/core/admin/import_sessions/show.html.erb +87 -0
  187. data/app/views/panda/core/admin/my_profile/edit.html.erb +51 -39
  188. data/app/views/panda/core/admin/my_profile/logins/show.html.erb +72 -0
  189. data/app/views/panda/core/admin/sessions/new.html.erb +1 -37
  190. data/app/views/panda/core/admin/shared/_breadcrumbs.html.erb +1 -18
  191. data/app/views/panda/core/admin/shared/_sidebar.html.erb +1 -99
  192. data/app/views/panda/core/admin/shared/_slideover.html.erb +3 -3
  193. data/app/views/panda/core/admin/tags/_form.html.erb +21 -0
  194. data/app/views/panda/core/admin/tags/edit.html.erb +4 -0
  195. data/app/views/panda/core/admin/tags/index.html.erb +41 -0
  196. data/app/views/panda/core/admin/tags/new.html.erb +4 -0
  197. data/app/views/panda/core/admin/users/activity.html.erb +90 -0
  198. data/app/views/panda/core/admin/users/edit.html.erb +48 -0
  199. data/app/views/panda/core/admin/users/index.html.erb +161 -0
  200. data/app/views/panda/core/admin/users/sessions.html.erb +103 -0
  201. data/app/views/panda/core/admin/users/show.html.erb +224 -0
  202. data/app/views/panda/core/shared/_footer.html.erb +1 -2
  203. data/app/views/panda/core/shared/_header.html.erb +4 -22
  204. data/config/brakeman.ignore +68 -31
  205. data/config/importmap.rb +13 -11
  206. data/config/routes.rb +49 -2
  207. data/db/migrate/20260202171614_create_panda_core_file_categories.rb +30 -0
  208. data/db/migrate/20260203000001_add_user_management_fields_to_panda_core_users.rb +21 -0
  209. data/db/migrate/20260203000002_create_panda_core_user_activities.rb +24 -0
  210. data/db/migrate/20260203000003_create_panda_core_user_sessions.rb +25 -0
  211. data/db/migrate/20260207100000_create_panda_core_presences.rb +21 -0
  212. data/db/migrate/20260213000001_fix_active_storage_attachments_record_id_type.rb +44 -0
  213. data/db/migrate/20260217000001_create_panda_core_feature_flags.rb +14 -0
  214. data/db/migrate/20260303000001_add_metadata_to_panda_core_users.rb +11 -0
  215. data/db/migrate/20260310000001_create_panda_core_tags.rb +27 -0
  216. data/db/migrate/20260310000002_create_panda_core_import_sessions.rb +29 -0
  217. data/lib/generators/panda/core/install_generator.rb +32 -0
  218. data/lib/generators/panda/core/templates/panda.rb +63 -0
  219. data/lib/panda/core/asset_loader.rb +5 -2
  220. data/lib/panda/core/authentication.rb +3 -3
  221. data/lib/panda/core/configuration.rb +195 -1
  222. data/lib/panda/core/engine/admin_controller_config.rb +3 -6
  223. data/lib/panda/core/engine/autoload_config.rb +21 -9
  224. data/lib/panda/core/engine/omniauth_config.rb +108 -38
  225. data/lib/panda/core/engine/pending_migrations_check.rb +46 -0
  226. data/lib/panda/core/engine/sqlite_schema_compatibility.rb +36 -0
  227. data/lib/panda/core/engine/{phlex_config.rb → view_component_config.rb} +5 -7
  228. data/lib/panda/core/engine.rb +90 -25
  229. data/lib/panda/core/middleware.rb +35 -0
  230. data/lib/panda/core/navigation_registry.rb +455 -0
  231. data/lib/panda/core/oauth_providers.rb +27 -0
  232. data/lib/panda/core/permission_registry.rb +89 -0
  233. data/lib/panda/core/search_registry.rb +69 -0
  234. data/lib/panda/core/seeds/file_categories.rb +30 -0
  235. data/lib/panda/core/shared/inflections_config.rb +1 -5
  236. data/lib/panda/core/testing/rails_helper.rb +87 -0
  237. data/lib/panda/core/testing/support/authentication_test_helpers.rb +22 -18
  238. data/lib/panda/core/testing/support/system/cuprite_helpers.rb +4 -4
  239. data/lib/panda/core/testing/view_component_test_controller.rb +19 -0
  240. data/lib/panda/core/version.rb +1 -1
  241. data/lib/panda/core/widget_registry.rb +50 -0
  242. data/lib/panda/core.rb +17 -8
  243. data/lib/tasks/panda/core/file_categories.rake +41 -0
  244. data/public/panda-core-assets/panda-core-0.12.5.css +2 -0
  245. data/public/panda-core-assets/panda-core-0.13.0.css +2 -0
  246. data/public/panda-core-assets/panda-core-0.14.0.css +2 -0
  247. data/public/panda-core-assets/panda-core-0.14.4.css +2 -0
  248. data/public/panda-core-assets/panda-core.css +2 -2
  249. data/public/panda-core-assets/vanilla-calendar.css +47 -0
  250. metadata +203 -26
  251. data/lib/panda/core/engine/inflections_config.rb +0 -23
@@ -14,6 +14,9 @@ module Panda
14
14
  :mailer_default_url_options,
15
15
  :session_token_cookie,
16
16
  :authentication_providers,
17
+ :authentication_provider_resolver,
18
+ :authentication_provider_gate,
19
+ :authentication_validator,
17
20
  :admin_path,
18
21
  :admin_navigation_items,
19
22
  :admin_dashboard_widgets,
@@ -26,13 +29,28 @@ module Panda
26
29
  :login_logo_path,
27
30
  :login_page_title,
28
31
  :admin_title,
32
+ :admin_logo,
33
+ :admin_settings_path,
34
+ :admin_logo_actions,
35
+ :admin_sidebar_footer,
29
36
  :initial_admin_breadcrumb,
30
37
  :dashboard_redirect_path,
31
38
  :avatar_variants,
32
39
  :avatar_max_file_size,
33
40
  :avatar_max_dimension,
34
41
  :avatar_optimization_quality,
35
- :avatar_image_processor
42
+ :avatar_image_processor,
43
+ :admin_user_menu_items,
44
+ :additional_head_content,
45
+ :compact_navigation,
46
+ :admin_user_edit_content,
47
+ :admin_user_after_update,
48
+ :admin_user_show_content,
49
+ :admin_user_index_columns,
50
+ :restrict_user_creation,
51
+ :after_user_invited,
52
+ :invite_form_content,
53
+ :post_authentication_redirect
36
54
 
37
55
  def initialize
38
56
  @user_class = "Panda::Core::User"
@@ -46,6 +64,9 @@ module Panda
46
64
  @mailer_default_url_options = {host: "localhost:3000"}
47
65
  @session_token_cookie = :panda_session
48
66
  @authentication_providers = {}
67
+ @authentication_provider_resolver = nil
68
+ @authentication_provider_gate = nil
69
+ @authentication_validator = nil
49
70
  @admin_path = "/admin"
50
71
  @default_theme = "default"
51
72
 
@@ -74,6 +95,14 @@ module Panda
74
95
  icon: "fa-solid fa-user"
75
96
  }
76
97
 
98
+ items << {
99
+ label: "Settings",
100
+ icon: "fa-solid fa-gear",
101
+ children: [
102
+ {label: "Feature Flags", path: "#{@admin_path}/feature_flags"}
103
+ ]
104
+ }
105
+
77
106
  items
78
107
  }
79
108
  @admin_dashboard_widgets = ->(user) { [] }
@@ -83,12 +112,24 @@ module Panda
83
112
 
84
113
  # Profile and UI customization
85
114
  @additional_user_params = []
115
+ # Themes offered in the My Profile dropdown as [label, value] pairs.
116
+ # Assigning this REPLACES the default list — host apps registering
117
+ # their own theme should include any built-ins they still want, e.g.
118
+ # config.available_themes = [["Default", "default"], ["Acme", "acme"]]
119
+ # The value is stamped as <html data-theme="..."> on admin pages;
120
+ # supply the matching html[data-theme='...'] variable block via
121
+ # config.additional_head_content (see app/assets/tailwind/application.css).
86
122
  @available_themes = [["Default", "default"], ["Sky", "sky"]]
87
123
  @login_logo_path = nil
88
124
  @login_page_title = "Panda Admin"
89
125
  @admin_title = "Panda Admin"
126
+ @admin_logo = nil # Proc/lambda returning HTML for sidebar logo area (e.g. SVG + title)
127
+ @admin_settings_path = nil # Path for the settings gear icon in the logo area (nil hides it)
128
+ @admin_logo_actions = [] # Array of {path:, icon:, title:} hashes rendered as icon links next to the settings gear
129
+ @admin_sidebar_footer = nil # Proc/lambda returning HTML for the bottom of the sidebar (replaces version text)
90
130
  @initial_admin_breadcrumb = nil # Proc that returns [label, path]
91
131
  @dashboard_redirect_path = nil # Path to redirect to after login (defaults to admin_root_path)
132
+ @additional_head_content = nil # Proc/lambda returning HTML to inject into <head> (e.g. theme stylesheets, fonts)
92
133
 
93
134
  # Avatar configuration
94
135
  @avatar_variants = {
@@ -101,6 +142,159 @@ module Panda
101
142
  @avatar_max_dimension = 800
102
143
  @avatar_optimization_quality = 85
103
144
  @avatar_image_processor = :vips # or :mini_magick
145
+
146
+ # Navigation density — false (spacious, default) or true (compact, for apps with many nav items)
147
+ @compact_navigation = false
148
+
149
+ # User management hooks — Procs that host apps set to inject content into user views/callbacks
150
+ @admin_user_edit_content = nil # Proc(user, form_builder, view_context) → HTML
151
+ @admin_user_after_update = nil # Proc(user, params, current_user)
152
+ @admin_user_show_content = nil # Proc(user, view_context) → HTML
153
+ @admin_user_index_columns = nil # Proc(user, view_context) → HTML
154
+
155
+ # Authentication restriction — when truthy, find_or_create_from_auth_hash
156
+ # will NOT create new User records for unknown emails on OAuth login.
157
+ # Accepts a boolean or a callable (receives auth_hash, returns truthy to restrict).
158
+ @restrict_user_creation = false
159
+
160
+ # Invitation hooks — host app can extend the invite flow
161
+ @after_user_invited = nil # Proc(user, params, current_user) — called after successful invite
162
+ @invite_form_content = nil # Proc(form_builder, view_context) → HTML — extra fields in invite form
163
+
164
+ # Post-authentication redirect — Proc(user, request) → URL string or nil.
165
+ # Called after successful OAuth login. Return a URL to redirect there,
166
+ # or nil to fall through to the default redirect logic.
167
+ @post_authentication_redirect = nil
168
+
169
+ # Legacy extensible user menu items (prefer NavigationRegistry with position: :bottom)
170
+ @admin_user_menu_items = []
171
+
172
+ # Register default user menu as a bottom navigation section (idempotent)
173
+ register_default_user_menu
174
+ end
175
+
176
+ # Returns the authentication providers for the given request.
177
+ # If an +authentication_provider_resolver+ proc is configured it is
178
+ # called with the request to allow per-tenant provider filtering.
179
+ # Otherwise the static +authentication_providers+ hash is returned.
180
+ def resolved_authentication_providers(request = nil)
181
+ if authentication_provider_resolver && request
182
+ authentication_provider_resolver.call(request)
183
+ else
184
+ authentication_providers
185
+ end
186
+ end
187
+
188
+ # Register a new admin navigation section via {NavigationRegistry}.
189
+ #
190
+ # Sections with the same label as an existing base section are skipped.
191
+ # Use +after:+ or +before:+ to control positioning relative to other sections.
192
+ #
193
+ # @param label [String] Section label displayed in the sidebar
194
+ # @param icon [String] FontAwesome icon class (e.g. "fa-solid fa-users")
195
+ # @param after [String, nil] Insert after the section with this label
196
+ # @param before [String, nil] Insert before the section with this label
197
+ # @param visible [Proc, nil] Lambda receiving user, hides section when false
198
+ # @param permission [Symbol, nil] Permission key — hides section unless user is authorized
199
+ # @param position [Symbol] :top (default) or :bottom
200
+ # @yield [NavigationRegistry::SectionContext] Optional block for adding items
201
+ #
202
+ # @example Add a section with items
203
+ # config.insert_admin_menu_section "Members",
204
+ # icon: "fa-solid fa-users",
205
+ # after: "Website" do |section|
206
+ # section.item "Onboarding", path: "members/onboarding"
207
+ # end
208
+ #
209
+ # @see NavigationRegistry.section
210
+ def insert_admin_menu_section(label, icon: nil, after: nil, before: nil, visible: nil, permission: nil, position: :top, &block)
211
+ Panda::Core::NavigationRegistry.section(label, icon: icon, after: after, before: before, visible: visible, permission: permission, position: position, &block)
212
+ end
213
+
214
+ # Register an item to be appended to an existing admin navigation section.
215
+ #
216
+ # If the target section doesn't exist at build time, the item is silently
217
+ # skipped. Use +path:+ for admin-relative paths or +url:+ for absolute URLs.
218
+ #
219
+ # @param label [String] Item label displayed in the sidebar
220
+ # @param section [String] Label of the target section to add to
221
+ # @param path [String, nil] Relative path (auto-prefixed with admin_path)
222
+ # @param url [String, nil] Absolute URL (used as-is, no prefixing)
223
+ # @param target [String, nil] HTML target attribute (e.g. "_blank")
224
+ # @param visible [Proc, nil] Lambda receiving user, hides item when false
225
+ # @param before [Symbol, String, nil] :all or label — position within section
226
+ # @param after [Symbol, String, nil] :all or label — position within section
227
+ # @param method [Symbol, nil] HTTP method (e.g. :delete)
228
+ # @param button_options [Hash] Extra options for button_to
229
+ # @param path_helper [Symbol, nil] Route helper resolved at build time
230
+ #
231
+ # @example Add to an existing section
232
+ # config.insert_admin_menu_item "Feature Flags",
233
+ # section: "Settings",
234
+ # path: "feature_flags"
235
+ #
236
+ # @example Permission-gated item
237
+ # config.insert_admin_menu_item "Suggestions",
238
+ # section: "Tools",
239
+ # path: "cms/content_suggestions",
240
+ # permission: :review_content
241
+ #
242
+ # @see NavigationRegistry.item
243
+ # rubocop:disable Metrics/ParameterLists
244
+ def insert_admin_menu_item(label, section:, path: nil, url: nil, target: nil,
245
+ visible: nil, permission: nil, before: nil, after: nil, method: nil, button_options: {}, path_helper: nil)
246
+ Panda::Core::NavigationRegistry.item(
247
+ label, section: section, path: path, url: url, target: target,
248
+ visible: visible, permission: permission, before: before, after: after,
249
+ method: method, button_options: button_options, path_helper: path_helper
250
+ )
251
+ end
252
+ # rubocop:enable Metrics/ParameterLists
253
+
254
+ # Convenience: add an item to the user menu (bottom section).
255
+ # @param label [String] Item label
256
+ # @param path [String, nil] Path (auto-prefixed with admin_path)
257
+ # @param url [String, nil] Full URL (used as-is)
258
+ # @param visible [Proc, nil] Lambda receiving user, hides item when false
259
+ # @param position [Symbol] :before_logout (default) or :top
260
+ def insert_admin_user_menu_item(label, path: nil, url: nil, visible: nil, position: :before_logout)
261
+ before_opt = (position == :before_logout) ? "Logout" : nil
262
+ before_opt = :all if position == :top
263
+ Panda::Core::NavigationRegistry.item(
264
+ label, section: "My Account", path: path, url: url,
265
+ visible: visible, before: before_opt
266
+ )
267
+ end
268
+
269
+ # Register a post-build filter that conditionally hides items by label.
270
+ # @param label [String] Label to match
271
+ # @param visible [Proc] Lambda receiving user — item hidden when false
272
+ def filter_admin_menu(label, visible:)
273
+ Panda::Core::NavigationRegistry.filter(label, visible: visible)
274
+ end
275
+
276
+ # Register a dashboard widget via WidgetRegistry.
277
+ # @param label [String] Widget label
278
+ # @param component [Proc] Lambda receiving user, returns a component instance
279
+ # @param visible [Proc, nil] Lambda receiving user, hides widget when false
280
+ # @param position [Integer] Sort order (lower first)
281
+ def register_admin_dashboard_widget(label, component:, visible: nil, position: 0)
282
+ Panda::Core::WidgetRegistry.register(label, component: component, visible: visible, position: position)
283
+ end
284
+
285
+ private
286
+
287
+ # Register the default user menu items as a bottom navigation section.
288
+ # Idempotent: skips if "My Account" is already registered.
289
+ def register_default_user_menu
290
+ return if Panda::Core::NavigationRegistry.sections.any? { |s| s[:label] == "My Account" }
291
+
292
+ Panda::Core::NavigationRegistry.section("My Account", position: :bottom) do |s|
293
+ s.item "My Profile", path: "my_profile/edit"
294
+ s.item "Login & Security", path: "my_profile/logins"
295
+ s.item "Logout", path_helper: :admin_logout_path, method: :delete,
296
+ button_options: {id: "logout-link", data: {turbo: false}}
297
+ end
104
298
  end
105
299
  end
106
300
 
@@ -11,12 +11,9 @@ module Panda
11
11
  # Create AdminController alias after controllers are loaded
12
12
  # This allows other gems to inherit from Panda::Core::AdminController
13
13
  config.to_prepare do
14
- # Use on_load to ensure ActionController is available
15
- ActiveSupport.on_load(:action_controller_base) do
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
14
+ unless Panda::Core.const_defined?(:AdminController)
15
+ admin_base = "Panda::Core::Admin::BaseController".safe_constantize
16
+ Panda::Core.const_set(:AdminController, admin_base) if admin_base
20
17
  end
21
18
  end
22
19
  end
@@ -6,17 +6,29 @@ module Panda
6
6
  module AutoloadConfig
7
7
  extend ActiveSupport::Concern
8
8
 
9
+ # Custom autoload paths for panda-core
10
+ # Note: Must be Strings (not Pathnames) for Rails 8.1.2+ compatibility
11
+ AUTOLOAD_DIRECTORIES = %w[
12
+ app/builders
13
+ app/components
14
+ app/services
15
+ app/models
16
+ app/helpers
17
+ app/constraints
18
+ ].freeze
19
+
9
20
  included do
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")
21
+ # Use initializer with before: to ensure paths are added before
22
+ # Rails freezes the autoload_paths array in Rails 8.1.2+
23
+ initializer "panda_core.set_autoload_paths", before: :set_autoload_paths do |app|
24
+ AUTOLOAD_DIRECTORIES.each do |dir|
25
+ path = root.join(dir).to_s
26
+ next unless File.directory?(path)
17
27
 
18
- # Mirror eager-load as needed
19
- config.eager_load_paths.concat(config.autoload_paths)
28
+ app.config.autoload_paths << path unless app.config.autoload_paths.include?(path)
29
+ app.config.eager_load_paths << path unless app.config.eager_load_paths.include?(path)
30
+ end
31
+ end
20
32
  end
21
33
  end
22
34
  end
@@ -26,27 +26,42 @@ module Panda
26
26
  "developer" => :developer
27
27
  }.freeze
28
28
 
29
- included do
30
- if respond_to?(:initializer)
31
- initializer "panda_core.omniauth" do |app|
32
- require_relative "../oauth_providers"
33
- Panda::Core::OAuthProviders.setup
34
-
35
- load_yaml_provider_overrides!
36
- mount_omniauth_middleware(app)
29
+ class_methods do
30
+ # Load YAML provider overrides during engine definition (before middleware setup)
31
+ def load_yaml_provider_overrides_early!
32
+ path = Panda::Core::Engine.root.join("config/providers.yml")
33
+ return unless File.exist?(path)
34
+
35
+ yaml = YAML.load_file(path) || {}
36
+ (yaml["providers"] || {}).each do |name, settings|
37
+ Panda::Core.config.authentication_providers[name.to_s] ||= {}
38
+ Panda::Core.config.authentication_providers[name.to_s].deep_merge!(settings)
39
+ end
40
+ end
37
41
 
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
42
- end
42
+ # Configure OmniAuth globals
43
+ def configure_omniauth_globals
44
+ OmniAuth.configure do |c|
45
+ c.allowed_request_methods = [:post]
46
+ c.path_prefix = "#{Panda::Core.config.admin_path}/auth"
47
+
48
+ # OmniAuth's built-in AuthenticityTokenProtection uses
49
+ # Rack::Protection's :csrf session key, but Rails stores its
50
+ # CSRF token under :_csrf_token. This session key mismatch
51
+ # causes "Forbidden" errors when submitting the admin login form.
52
+ #
53
+ # Disabling OmniAuth's request_validation_phase is safe because:
54
+ # - Production OAuth providers (Google, GitHub, Microsoft) are
55
+ # protected by the OAuth state parameter in the callback phase
56
+ # - The developer provider only runs in development/test
57
+ # - The login form still requires POST (allowed_request_methods)
58
+ c.request_validation_phase = nil
43
59
  end
44
60
  end
45
61
  end
46
62
 
47
- private
48
-
49
- # 1. YAML overrides
63
+ # Instance method for loading YAML provider overrides (for testing)
64
+ # @deprecated Use class method load_yaml_provider_overrides_early! instead
50
65
  def load_yaml_provider_overrides!
51
66
  path = Panda::Core::Engine.root.join("config/providers.yml")
52
67
  return unless File.exist?(path)
@@ -58,31 +73,15 @@ module Panda
58
73
  end
59
74
  end
60
75
 
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
76
+ # Configure a single provider on the OmniAuth builder
77
+ # @param builder [OmniAuth::Builder] the OmniAuth builder instance
78
+ # @param name [String] the provider name (may be an alias)
79
+ # @param settings [Hash] provider configuration
81
80
  def configure_provider(builder, name, settings)
82
81
  symbol = PROVIDER_REGISTRY[name.to_s]
83
82
 
84
83
  unless symbol
85
- Rails.logger.warn("[panda-core] Unknown OmniAuth provider: #{name.inspect}")
84
+ Rails.logger.warn("[panda-core] Unknown OmniAuth provider: #{name.inspect}") if defined?(Rails.logger)
86
85
  return
87
86
  end
88
87
 
@@ -91,7 +90,7 @@ module Panda
91
90
  # Skip providers without credentials (except developer which doesn't need them)
92
91
  has_credentials = settings[:client_id].present? && settings[:client_secret].present?
93
92
  if symbol != :developer && !has_credentials
94
- Rails.logger.info("[panda-core] Skipping OmniAuth provider #{name.inspect}: missing client_id or client_secret")
93
+ Rails.logger.info("[panda-core] Skipping OmniAuth provider #{name.inspect}: missing client_id or client_secret") if defined?(Rails.logger)
95
94
  return
96
95
  end
97
96
 
@@ -104,6 +103,77 @@ module Panda
104
103
  builder.provider symbol, options
105
104
  end
106
105
  end
106
+
107
+ included do
108
+ # Only run Rails Engine-specific code when included into an actual Engine
109
+ # This prevents errors when the module is included into test dummy classes
110
+ next unless self < ::Rails::Engine
111
+
112
+ # Load YAML overrides early during engine definition so they're available
113
+ # when the OmniAuth middleware block is evaluated
114
+ load_yaml_provider_overrides_early!
115
+
116
+ if respond_to?(:initializer)
117
+ # Set up OAuth providers
118
+ config.before_initialize do |app|
119
+ require_relative "../oauth_providers"
120
+ Panda::Core::OAuthProviders.setup
121
+ end
122
+
123
+ # Configure OmniAuth globals AFTER all initializers have run
124
+ # This ensures Panda::Core.config.admin_path has been set by the app
125
+ initializer "panda_core.omniauth_globals" do |app|
126
+ app.config.after_initialize do
127
+ Panda::Core::Engine.configure_omniauth_globals
128
+ end
129
+ end
130
+ end
131
+
132
+ # Add OmniAuth middleware during engine configuration
133
+ # Rails 8.1.2+ freezes the middleware stack before initializers run
134
+ config.app_middleware.use OmniAuth::Builder do
135
+ # Capture the builder instance for provider configuration
136
+ builder = self
137
+ Panda::Core.config.authentication_providers.each do |name, settings|
138
+ symbol = PROVIDER_REGISTRY[name.to_s]
139
+
140
+ unless symbol
141
+ Rails.logger.warn("[panda-core] Unknown OmniAuth provider: #{name.inspect}") if defined?(Rails.logger)
142
+ next
143
+ end
144
+
145
+ next if symbol == :developer && !Rails.env.development?
146
+
147
+ # Skip providers without credentials (except developer which doesn't need them)
148
+ has_credentials = settings[:client_id].present? && settings[:client_secret].present?
149
+ if symbol != :developer && !has_credentials
150
+ Rails.logger.info("[panda-core] Skipping OmniAuth provider #{name.inspect}: missing client_id or client_secret") if defined?(Rails.logger)
151
+ next
152
+ end
153
+
154
+ options = (settings[:options] || {}).dup
155
+ options[:name] = settings[:path_name] if settings[:path_name].present?
156
+
157
+ # Inject a setup lambda that gates providers per-request (e.g. per-tenant)
158
+ # and allows dynamic redirect_uri override for multi-subdomain OAuth flows.
159
+ gate = Panda::Core.config.authentication_provider_gate
160
+ if gate && symbol != :developer
161
+ provider_name = name.to_s
162
+ options[:setup] = ->(env) {
163
+ unless gate.call(provider_name, env)
164
+ raise OmniAuth::Error, "Provider not enabled for this workspace"
165
+ end
166
+ }
167
+ end
168
+
169
+ if settings[:client_id] && settings[:client_secret]
170
+ builder.provider symbol, settings[:client_id], settings[:client_secret], options
171
+ else
172
+ builder.provider symbol, options
173
+ end
174
+ end
175
+ end
176
+ end
107
177
  end
108
178
  end
109
179
  end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ class Engine < ::Rails::Engine
6
+ module PendingMigrationsCheck
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ initializer "panda_core.pending_migrations_check", after: :load_config_initializers do
11
+ config.after_initialize do
12
+ next unless Rails.env.local?
13
+
14
+ check_for_pending_panda_migrations
15
+ rescue ActiveRecord::NoDatabaseError, ActiveRecord::ConnectionNotEstablished, NoMethodError
16
+ # Skip when DB isn't available (asset precompilation, etc.)
17
+ # or when migration API differs across Rails versions
18
+ end
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def check_for_pending_panda_migrations
25
+ paths = ActiveRecord::Migrator.migrations_paths
26
+ context = ActiveRecord::MigrationContext.new(paths)
27
+ return unless context.needs_migration?
28
+
29
+ pending = context.open.pending_migrations
30
+ panda_pending = pending.select { |m| m.filename.match?(/panda/i) }
31
+ return if panda_pending.empty?
32
+
33
+ install_tasks = Panda::Core::ModuleRegistry.modules.keys
34
+ .unshift("panda-core").uniq
35
+ .map { |gem_name| "#{gem_name.tr("-", "_")}:install:migrations" }
36
+ .join(" ")
37
+
38
+ Rails.logger.warn(
39
+ "\n⚠️ #{panda_pending.size} pending Panda migration(s) detected.\n" \
40
+ " Run: bin/rails #{install_tasks} db:migrate\n"
41
+ )
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ module SqliteSchemaCompatibility
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ initializer "panda_core.sqlite_schema_compatibility" do
10
+ next unless ActiveRecord::Base.configurations.configs_for(env_name: Rails.env).any? { |cfg| cfg.adapter == "sqlite3" }
11
+
12
+ require "active_record/connection_adapters/sqlite3_adapter"
13
+
14
+ sqlite_adapter = ActiveRecord::ConnectionAdapters::SQLite3Adapter
15
+ unless sqlite_adapter.singleton_class.method_defined?(:native_database_types_with_panda_sqlite_compatibility)
16
+ sqlite_adapter.singleton_class.prepend(Module.new do
17
+ def native_database_types_with_panda_sqlite_compatibility
18
+ super.merge(
19
+ uuid: {name: "varchar"},
20
+ jsonb: {name: "json"}
21
+ )
22
+ end
23
+ alias_method :native_database_types, :native_database_types_with_panda_sqlite_compatibility
24
+ end)
25
+ end
26
+
27
+ table_definition = ActiveRecord::ConnectionAdapters::SQLite3::TableDefinition
28
+ # Rails' SQLite TableDefinition intentionally omits these helpers, but our
29
+ # cross-database migrations/schema use them. We define them once at boot.
30
+ table_definition.send(:define_column_methods, :uuid) unless table_definition.method_defined?(:uuid)
31
+ table_definition.send(:define_column_methods, :jsonb) unless table_definition.method_defined?(:jsonb)
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -3,8 +3,8 @@
3
3
  module Panda
4
4
  module Core
5
5
  class Engine < ::Rails::Engine
6
- # Phlex configuration
7
- module PhlexConfig
6
+ # ViewComponent configuration
7
+ module ViewComponentConfig
8
8
  extend ActiveSupport::Concern
9
9
 
10
10
  included do
@@ -26,12 +26,10 @@ module Panda
26
26
  end
27
27
  end
28
28
 
29
- # Load Phlex base component after Rails application is initialized
29
+ # Load ViewComponent base component after Rails application is initialized
30
30
  # This ensures Rails.application.routes is available
31
- initializer "panda_core.phlex_base", after: :load_config_initializers do
32
- require "phlex"
33
- require "phlex-rails"
34
- require "literal"
31
+ initializer "panda_core.view_component_base", after: :load_config_initializers do
32
+ require "view_component"
35
33
  require "tailwind_merge"
36
34
 
37
35
  # Load the base component