panda-core 0.12.2 → 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 (255) 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 +413 -131
  6. data/app/builders/panda/core/form_builder.rb +129 -27
  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 +15 -19
  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 +37 -32
  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 +34 -69
  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 +85 -8
  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 +96 -18
  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 -67
  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 -38
  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 +6 -3
  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 +4 -3
  223. data/lib/panda/core/engine/autoload_config.rb +21 -9
  224. data/lib/panda/core/engine/omniauth_config.rb +114 -32
  225. data/lib/panda/core/engine/pending_migrations_check.rb +46 -0
  226. data/lib/panda/core/engine/route_config.rb +1 -1
  227. data/lib/panda/core/engine/sqlite_schema_compatibility.rb +36 -0
  228. data/lib/panda/core/engine/{phlex_config.rb → view_component_config.rb} +5 -7
  229. data/lib/panda/core/engine.rb +90 -25
  230. data/lib/panda/core/middleware.rb +35 -0
  231. data/lib/panda/core/module_registry.rb +21 -13
  232. data/lib/panda/core/navigation_registry.rb +455 -0
  233. data/lib/panda/core/oauth_providers.rb +27 -0
  234. data/lib/panda/core/permission_registry.rb +89 -0
  235. data/lib/panda/core/search_registry.rb +69 -0
  236. data/lib/panda/core/seeds/file_categories.rb +30 -0
  237. data/lib/panda/core/shared/inflections_config.rb +1 -5
  238. data/lib/panda/core/testing/rails_helper.rb +87 -0
  239. data/lib/panda/core/testing/support/authentication_test_helpers.rb +22 -18
  240. data/lib/panda/core/testing/support/system/cuprite_helpers.rb +4 -4
  241. data/lib/panda/core/testing/view_component_test_controller.rb +19 -0
  242. data/lib/panda/core/version.rb +1 -1
  243. data/lib/panda/core/widget_registry.rb +50 -0
  244. data/lib/panda/core.rb +20 -0
  245. data/lib/tasks/panda/core/file_categories.rake +41 -0
  246. data/public/panda-core-assets/panda-core-0.12.2.css +2 -0
  247. data/public/panda-core-assets/panda-core-0.12.3.css +2 -0
  248. data/public/panda-core-assets/panda-core-0.12.5.css +2 -0
  249. data/public/panda-core-assets/panda-core-0.13.0.css +2 -0
  250. data/public/panda-core-assets/panda-core-0.14.0.css +2 -0
  251. data/public/panda-core-assets/panda-core-0.14.4.css +2 -0
  252. data/public/panda-core-assets/panda-core.css +2 -2
  253. data/public/panda-core-assets/vanilla-calendar.css +47 -0
  254. metadata +205 -26
  255. data/lib/panda/core/engine/inflections_config.rb +0 -23
@@ -0,0 +1,455 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ # Declarative registry for extending admin sidebar navigation.
6
+ #
7
+ # Gems and host apps register navigation sections and items at boot time,
8
+ # and the registry merges them with the base +admin_navigation_items+ lambda
9
+ # at render time. This avoids the fragile lambda-wrapping pattern where each
10
+ # gem must capture the existing proc, create a new one, and manipulate the
11
+ # resulting array with index lookups and insertions.
12
+ #
13
+ # == Path resolution
14
+ #
15
+ # * +path:+ — auto-prefixed with +admin_path+ (e.g. <tt>path: "feature_flags"</tt>
16
+ # becomes <tt>"/admin/feature_flags"</tt>)
17
+ # * +url:+ — used as-is, for full URLs or absolute paths
18
+ # * +path_helper:+ — a route helper symbol resolved at build time via +helpers+
19
+ # * +target:+ — HTML target attribute (e.g. <tt>"_blank"</tt>), omitted when +nil+
20
+ #
21
+ # == Visibility and filtering
22
+ #
23
+ # * +visible:+ — lambda receiving user, evaluated at build time; item/section
24
+ # hidden when false
25
+ # * +filter()+ — post-build removal of items/sections by label, conditional
26
+ # on a user-receiving lambda
27
+ #
28
+ # == Usage
29
+ #
30
+ # Convenience methods on {Panda::Core::Configuration} delegate here, so the
31
+ # typical usage is inside a +Panda::Core.configure+ block:
32
+ #
33
+ # # In a host app initializer or engine:
34
+ # Panda::Core.configure do |config|
35
+ # # Add a new section with items, positioned after "Website"
36
+ # config.insert_admin_menu_section "Members",
37
+ # icon: "fa-solid fa-users",
38
+ # after: "Website" do |section|
39
+ # section.item "Onboarding", path: "members/onboarding"
40
+ # end
41
+ #
42
+ # # Add an item to an existing section
43
+ # config.insert_admin_menu_item "Feature Flags",
44
+ # section: "Settings",
45
+ # path: "feature_flags"
46
+ #
47
+ # # Permission-based visibility
48
+ # config.insert_admin_menu_item "Suggestions",
49
+ # section: "Tools",
50
+ # path: "cms/content_suggestions",
51
+ # visible: -> (user) { user.admin? || user.has_permission?(:review_content) }
52
+ #
53
+ # # Post-build filter (hides for non-admins)
54
+ # config.filter_admin_menu "Roles",
55
+ # visible: -> (user) { user.admin? }
56
+ #
57
+ # # Bottom section (rendered with UserMenuComponent)
58
+ # config.insert_admin_menu_section "Notifications",
59
+ # position: :bottom do |s|
60
+ # s.item "All", path: "notifications"
61
+ # end
62
+ # end
63
+ #
64
+ # You can also call the class methods directly:
65
+ #
66
+ # Panda::Core::NavigationRegistry.section("Tools", icon: "fa-solid fa-wrench")
67
+ # Panda::Core::NavigationRegistry.item("Database", section: "Tools", path: "tools/database")
68
+ #
69
+ # == Build order
70
+ #
71
+ # {.build} is called once per request by the sidebar component:
72
+ #
73
+ # 1. The base +admin_navigation_items+ lambda is called (backward compatible)
74
+ # 2. Registered sections are inserted (skipped if a section with the same label
75
+ # already exists in the base)
76
+ # 3. Registered items are appended to their target sections
77
+ # 4. Legacy +admin_user_menu_items+ are migrated into the "My Account" bottom section
78
+ # 5. Visibility lambdas are evaluated; items/sections hidden when +visible:+ returns false
79
+ # 6. Post-build filters are applied
80
+ #
81
+ # Paths are resolved at build time, so +admin_path+ can be configured after
82
+ # registrations are made.
83
+ class NavigationRegistry
84
+ # Collects items added inside a {.section} block.
85
+ #
86
+ # @example
87
+ # NavigationRegistry.section("Members", icon: "fa-solid fa-users") do |s|
88
+ # s.item "Onboarding", path: "members/onboarding"
89
+ # s.item "Directory", path: "members/directory"
90
+ # end
91
+ class SectionContext
92
+ attr_reader :items
93
+
94
+ def initialize
95
+ @items = []
96
+ end
97
+
98
+ # Register an item within this section.
99
+ # @param label [String] Display label
100
+ # @param path [String, nil] Relative path (auto-prefixed with admin_path at build time)
101
+ # @param url [String, nil] Absolute URL (used as-is)
102
+ # @param target [String, nil] HTML target attribute (e.g. "_blank")
103
+ # @param visible [Proc, nil] Lambda receiving user, hides item when false
104
+ # @param permission [Symbol, nil] Permission key — item hidden unless user is authorized.
105
+ # Checked via +config.authorization_policy+. Overridden by +visible:+ if both given.
106
+ # @param method [Symbol, nil] HTTP method (e.g. :delete for logout buttons)
107
+ # @param button_options [Hash] Extra options for button_to rendering
108
+ # @param path_helper [Symbol, nil] Route helper name, resolved at build time via helpers
109
+ # rubocop:disable Metrics/ParameterLists
110
+ def item(label, path: nil, url: nil, target: nil, visible: nil, permission: nil,
111
+ method: nil, button_options: {}, path_helper: nil, badge: nil, badge_color: nil)
112
+ effective_visible = visible || permission_to_visible(permission)
113
+ @items << {
114
+ label: label, path: path, url: url, target: target,
115
+ visible: effective_visible, method: method, button_options: button_options,
116
+ path_helper: path_helper, badge: badge, badge_color: badge_color
117
+ }
118
+ end
119
+ # rubocop:enable Metrics/ParameterLists
120
+
121
+ private
122
+
123
+ # Convert a permission key into a visibility lambda.
124
+ # @param permission [Symbol, nil]
125
+ # @return [Proc, nil]
126
+ def permission_to_visible(permission)
127
+ return nil unless permission
128
+
129
+ ->(user) {
130
+ next true if user.respond_to?(:admin?) && user.admin?
131
+ policy = Panda::Core.config.authorization_policy
132
+ policy&.call(user, permission, nil) || false
133
+ }
134
+ end
135
+ end
136
+
137
+ @sections = []
138
+ @items = []
139
+ @filters = []
140
+
141
+ class << self
142
+ attr_reader :sections, :items, :filters
143
+
144
+ # Register a new navigation section.
145
+ #
146
+ # If a section with the same label already exists in the base navigation
147
+ # (from the +admin_navigation_items+ lambda), the registration is skipped
148
+ # at build time — this prevents duplicate sections.
149
+ #
150
+ # @param label [String] Section label displayed in the sidebar
151
+ # @param icon [String] FontAwesome icon class (e.g. "fa-solid fa-users")
152
+ # @param after [String, nil] Insert after the section with this label
153
+ # @param before [String, nil] Insert before the section with this label
154
+ # @param visible [Proc, nil] Lambda receiving user, hides section when false
155
+ # @param permission [Symbol, nil] Permission key — section hidden unless user is authorized.
156
+ # Checked via +config.authorization_policy+. Overridden by +visible:+ if both given.
157
+ # @param position [Symbol] :top (default) or :bottom — controls sidebar placement
158
+ # @yield [SectionContext] Optional block for adding items to the section
159
+ #
160
+ # @example Add a section with items after "Website"
161
+ # section("Members", icon: "fa-solid fa-users", after: "Website") do |s|
162
+ # s.item "Onboarding", path: "members/onboarding"
163
+ # end
164
+ #
165
+ # @example Add a permission-gated section
166
+ # section("Settings", icon: "fa-solid fa-gear", permission: :manage_settings)
167
+ #
168
+ # @example Add a bottom section (rendered with UserMenuComponent)
169
+ # section("Notifications", position: :bottom) do |s|
170
+ # s.item "All", path: "notifications"
171
+ # end
172
+ # rubocop:disable Metrics/ParameterLists
173
+ def section(label, icon: nil, after: nil, before: nil, visible: nil, permission: nil, position: :top, &block)
174
+ context = SectionContext.new
175
+ yield context if block
176
+
177
+ effective_visible = visible || permission_to_visible(permission)
178
+
179
+ @sections << {
180
+ label: label,
181
+ icon: icon,
182
+ after: after,
183
+ before: before,
184
+ visible: effective_visible,
185
+ position: position,
186
+ items: context.items
187
+ }
188
+ end
189
+ # rubocop:enable Metrics/ParameterLists
190
+
191
+ # Register an item to be appended to a named section.
192
+ #
193
+ # The target section can come from either the base lambda or a
194
+ # previously registered section. If the section doesn't exist at
195
+ # build time, the item is silently skipped.
196
+ #
197
+ # @param label [String] Item label displayed in the sidebar
198
+ # @param section [String] Label of the target section to add to
199
+ # @param path [String, nil] Relative path (auto-prefixed with admin_path)
200
+ # @param url [String, nil] Absolute URL (used as-is, no prefixing)
201
+ # @param target [String, nil] HTML target attribute (e.g. "_blank")
202
+ # @param visible [Proc, nil] Lambda receiving user, hides item when false
203
+ # @param permission [Symbol, nil] Permission key — item hidden unless user is authorized.
204
+ # Checked via +config.authorization_policy+. Overridden by +visible:+ if both given.
205
+ # @param before [Symbol, String, nil] :all to insert at beginning, or label to insert before
206
+ # @param after [Symbol, String, nil] :all to insert at end (default), or label to insert after
207
+ # @param method [Symbol, nil] HTTP method (e.g. :delete for logout buttons)
208
+ # @param button_options [Hash] Extra options for button_to rendering
209
+ # @param path_helper [Symbol, nil] Route helper name, resolved at build time via helpers
210
+ #
211
+ # @example Add to an existing section with auto-prefixed path
212
+ # item("Feature Flags", section: "Settings", path: "feature_flags")
213
+ #
214
+ # @example Permission-gated item
215
+ # item("Roles", section: "Settings", path: "cms/roles",
216
+ # permission: :manage_roles)
217
+ # rubocop:disable Metrics/ParameterLists
218
+ def item(label, section:, path: nil, url: nil, target: nil, visible: nil, permission: nil,
219
+ before: nil, after: nil, method: nil, button_options: {}, path_helper: nil)
220
+ effective_visible = visible || permission_to_visible(permission)
221
+ @items << {
222
+ label: label,
223
+ section: section,
224
+ path: path,
225
+ url: url,
226
+ target: target,
227
+ visible: effective_visible,
228
+ before: before,
229
+ after: after,
230
+ method: method,
231
+ button_options: button_options,
232
+ path_helper: path_helper
233
+ }
234
+ end
235
+ # rubocop:enable Metrics/ParameterLists
236
+
237
+ # Register a post-build filter that conditionally hides items by label.
238
+ # Walks all sections and their children during build().
239
+ # @param label [String] Label to match
240
+ # @param visible [Proc] Lambda receiving user — item hidden when false
241
+ def filter(label, visible:)
242
+ @filters << {label: label, visible: visible}
243
+ end
244
+
245
+ # Build the final navigation array for the current user.
246
+ #
247
+ # Called once per request by the sidebar component. Combines the base
248
+ # +admin_navigation_items+ lambda with all registered sections, items,
249
+ # and filters.
250
+ #
251
+ # @param user [Object] The current authenticated user
252
+ # @param helpers [Object, nil] View helpers for resolving path_helper: symbols
253
+ # @return [Array<Hash>] Navigation items ready for rendering
254
+ def build(user, helpers: nil)
255
+ base = Panda::Core.config.admin_navigation_items&.call(user) || []
256
+ admin_path = Panda::Core.config.admin_path
257
+
258
+ # Convert permission: keys from base lambda items into _visible lambdas
259
+ apply_permission_visibility!(base)
260
+
261
+ # Apply registered sections
262
+ @sections.each do |section|
263
+ # Skip if a section with this label already exists in base
264
+ next if base.any? { |item| item[:label] == section[:label] }
265
+
266
+ entry = {label: section[:label], icon: section[:icon], position: section[:position], _visible: section[:visible]}
267
+ if section[:items].any?
268
+ entry[:children] = section[:items].map { |item|
269
+ resolved = resolve_item(item, admin_path, helpers)
270
+ resolved[:_visible] = item[:visible]
271
+ resolved
272
+ }
273
+ end
274
+
275
+ insert_section(base, entry, after: section[:after], before: section[:before])
276
+ end
277
+
278
+ # Apply registered items to their target sections
279
+ @items.each do |item|
280
+ target_section = base.find { |s| s[:label] == item[:section] }
281
+ next unless target_section
282
+
283
+ target_section[:children] ||= []
284
+ resolved = resolve_item(item, admin_path, helpers)
285
+ resolved[:_visible] = item[:visible]
286
+
287
+ insert_item(target_section[:children], resolved, before: item[:before], after: item[:after])
288
+ end
289
+
290
+ # Migrate legacy admin_user_menu_items into the "My Account" bottom section
291
+ legacy_items = Panda::Core.config.admin_user_menu_items
292
+ if legacy_items&.any?
293
+ my_account = base.find { |s| s[:label] == "My Account" && s[:position] == :bottom }
294
+ if my_account
295
+ legacy_items.each do |menu_item|
296
+ next if menu_item[:path].blank? && menu_item[:url].blank?
297
+ resolved = {label: menu_item[:label], path: menu_item[:path]}
298
+ resolved[:_visible] = menu_item[:visible]
299
+ # Insert before Logout if it exists
300
+ logout_idx = my_account[:children]&.index { |c| c[:label] == "Logout" }
301
+ if logout_idx
302
+ my_account[:children].insert(logout_idx, resolved)
303
+ else
304
+ (my_account[:children] ||= []) << resolved
305
+ end
306
+ end
307
+ end
308
+ end
309
+
310
+ # Apply section-level visible: — remove sections hidden for this user
311
+ base.reject! { |entry| entry[:_visible] && !entry[:_visible].call(user) }
312
+
313
+ # Apply item-level visible: — remove children hidden for this user
314
+ base.each do |entry|
315
+ next unless entry[:children]
316
+ entry[:children].reject! { |child| child[:_visible] && !child[:_visible].call(user) }
317
+ end
318
+
319
+ # Apply registered filters — walk all sections and children
320
+ @filters.each do |filter|
321
+ base.reject! { |entry| entry[:label] == filter[:label] && !filter[:visible].call(user) }
322
+ base.each do |entry|
323
+ next unless entry[:children]
324
+ entry[:children].reject! { |child| child[:label] == filter[:label] && !filter[:visible].call(user) }
325
+ end
326
+ end
327
+
328
+ # Clean up internal keys before returning
329
+ base.each do |entry|
330
+ entry.delete(:_visible)
331
+ entry[:position] ||= :top
332
+ entry[:children]&.each { |child| child.delete(:_visible) }
333
+ end
334
+
335
+ base
336
+ end
337
+
338
+ # Clear all registrations and re-register defaults (for test isolation).
339
+ # This ensures the default "My Account" bottom section is always present
340
+ # after a reset, preventing order-dependent test failures.
341
+ def reset!
342
+ @sections = []
343
+ @items = []
344
+ @filters = []
345
+ Panda::Core.config.send(:register_default_user_menu)
346
+ end
347
+
348
+ private
349
+
350
+ # Convert permission: keys into _visible lambdas for base nav items.
351
+ # Walks sections and their children from the base lambda.
352
+ def apply_permission_visibility!(items)
353
+ items.each do |item|
354
+ if item[:permission] && !item[:_visible]
355
+ item[:_visible] = permission_to_visible(item.delete(:permission))
356
+ end
357
+ next unless item[:children]
358
+ item[:children].each do |child|
359
+ if child[:permission] && !child[:_visible]
360
+ child[:_visible] = permission_to_visible(child.delete(:permission))
361
+ end
362
+ end
363
+ end
364
+ end
365
+
366
+ # Convert a permission key into a visibility lambda.
367
+ # Admin users always pass; others are checked via authorization_policy.
368
+ # @param permission [Symbol, nil]
369
+ # @return [Proc, nil]
370
+ def permission_to_visible(permission)
371
+ return nil unless permission
372
+
373
+ ->(user) {
374
+ next true if user.respond_to?(:admin?) && user.admin?
375
+ policy = Panda::Core.config.authorization_policy
376
+ policy&.call(user, permission, nil) || false
377
+ }
378
+ end
379
+
380
+ # Resolve path:, url:, or path_helper: into a final :path value.
381
+ # Includes :target, :method, :button_options when present.
382
+ def resolve_item(item, admin_path, helpers = nil)
383
+ resolved = {label: item[:label]}
384
+
385
+ if item[:path_helper] && helpers
386
+ resolved[:path] = helpers.panda_core.public_send(item[:path_helper])
387
+ elsif item[:url]
388
+ resolved[:path] = item[:url]
389
+ elsif item[:path]
390
+ resolved[:path] = "#{admin_path}/#{item[:path]}"
391
+ elsif item[:path_helper]
392
+ # Keep path_helper for template-level resolution when helpers unavailable
393
+ resolved[:path_helper] = item[:path_helper]
394
+ end
395
+
396
+ resolved[:target] = item[:target] if item[:target]
397
+ resolved[:method] = item[:method] if item[:method]
398
+ resolved[:button_options] = item[:button_options] if item[:button_options]&.any?
399
+ resolved[:badge] = item[:badge] if item[:badge]
400
+ resolved[:badge_color] = item[:badge_color] if item[:badge_color]
401
+ resolved
402
+ end
403
+
404
+ # Insert a section entry into the base array at the correct position.
405
+ def insert_section(base, entry, after: nil, before: nil)
406
+ if after
407
+ index = base.index { |item| item[:label] == after }
408
+ if index
409
+ base.insert(index + 1, entry)
410
+ return
411
+ end
412
+ end
413
+
414
+ if before
415
+ index = base.index { |item| item[:label] == before }
416
+ if index
417
+ base.insert(index, entry)
418
+ return
419
+ end
420
+ end
421
+
422
+ # Default: append to end
423
+ base << entry
424
+ end
425
+
426
+ # Insert an item into a section's children at the correct position.
427
+ def insert_item(children, entry, before: nil, after: nil)
428
+ if before == :all
429
+ children.insert(0, entry)
430
+ return
431
+ end
432
+
433
+ if before.is_a?(String)
434
+ index = children.index { |child| child[:label] == before }
435
+ if index
436
+ children.insert(index, entry)
437
+ return
438
+ end
439
+ end
440
+
441
+ if after.is_a?(String)
442
+ index = children.index { |child| child[:label] == after }
443
+ if index
444
+ children.insert(index + 1, entry)
445
+ return
446
+ end
447
+ end
448
+
449
+ # Default (including after: :all): append to end
450
+ children << entry
451
+ end
452
+ end
453
+ end
454
+ end
455
+ end
@@ -1,6 +1,25 @@
1
1
  module Panda
2
2
  module Core
3
3
  module OAuthProviders
4
+ # Provider metadata for display purposes
5
+ PROVIDER_INFO = {
6
+ microsoft_graph: {
7
+ name: "Microsoft",
8
+ icon: "fa-brands fa-microsoft",
9
+ color: "#00a4ef"
10
+ },
11
+ google_oauth2: {
12
+ name: "Google",
13
+ icon: "fa-brands fa-google",
14
+ color: "#4285f4"
15
+ },
16
+ github: {
17
+ name: "GitHub",
18
+ icon: "fa-brands fa-github",
19
+ color: "#333"
20
+ }
21
+ }.freeze
22
+
4
23
  def self.setup
5
24
  providers = []
6
25
 
@@ -27,6 +46,14 @@ module Panda
27
46
 
28
47
  providers
29
48
  end
49
+
50
+ def self.info(provider)
51
+ PROVIDER_INFO[provider.to_sym] || {
52
+ name: provider.to_s.titleize,
53
+ icon: "fa-solid fa-circle-user",
54
+ color: "#6b7280"
55
+ }
56
+ end
30
57
  end
31
58
  end
32
59
  end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ # Registry for controller-level permission requirements.
6
+ #
7
+ # Gems and host apps register which permissions are needed for each
8
+ # controller action. The {AdminAuthorization} concern reads this registry
9
+ # at request time to enforce access control.
10
+ #
11
+ # == Usage
12
+ #
13
+ # # In a gem engine (config.to_prepare or initializer):
14
+ # Panda::Core::PermissionRegistry.register(
15
+ # "Panda::CMS::Admin::PagesController",
16
+ # index: :edit_content,
17
+ # edit: :edit_content,
18
+ # destroy: :delete_content
19
+ # )
20
+ #
21
+ # # In a host app initializer:
22
+ # Panda::Core::PermissionRegistry.register(
23
+ # "Admin::HealthcareProvidersController",
24
+ # index: :manage_providers,
25
+ # destroy: :manage_providers
26
+ # )
27
+ #
28
+ # Actions not registered are allowed for any authenticated admin user.
29
+ # Controllers not registered are also allowed (open by default).
30
+ #
31
+ # == How enforcement works
32
+ #
33
+ # The {AdminAuthorization} concern calls {.permission_for} to look up
34
+ # the required permission, then delegates to +authorize!+ (from
35
+ # {Authorizable}) which checks +config.authorization_policy+.
36
+ #
37
+ # Without panda-cms-pro, the default policy is +user.admin?+ — so only
38
+ # admins pass permission checks. With Pro loaded, the policy uses the
39
+ # role system, allowing non-admin users with the right permissions.
40
+ class PermissionRegistry
41
+ class << self
42
+ # Register permission requirements for a controller.
43
+ #
44
+ # Merges with any previously registered permissions for the same
45
+ # controller, so multiple gems can contribute to the same controller.
46
+ #
47
+ # @param controller_name [String] Fully-qualified controller class name
48
+ # @param permissions [Hash{Symbol => Symbol}] action → permission_key mapping
49
+ def register(controller_name, **permissions)
50
+ registry[controller_name] = (registry[controller_name] || {}).merge(permissions)
51
+ end
52
+
53
+ # Look up the required permission for a specific controller action.
54
+ #
55
+ # @param controller_name [String] Fully-qualified controller class name
56
+ # @param action [Symbol] The controller action name
57
+ # @return [Symbol, nil] The required permission key, or nil if not registered
58
+ def permission_for(controller_name, action)
59
+ registry.dig(controller_name, action)
60
+ end
61
+
62
+ # Return all registered permissions for a controller.
63
+ #
64
+ # @param controller_name [String] Fully-qualified controller class name
65
+ # @return [Hash{Symbol => Symbol}, nil] action → permission_key mapping
66
+ def permissions_for(controller_name)
67
+ registry[controller_name]
68
+ end
69
+
70
+ # Return the full registry (deep copy).
71
+ # @return [Hash]
72
+ def all
73
+ registry.transform_values(&:dup)
74
+ end
75
+
76
+ # Clear all registrations (for test isolation).
77
+ def reset!
78
+ @registry = {}
79
+ end
80
+
81
+ private
82
+
83
+ def registry
84
+ @registry ||= {}
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ class SearchRegistry
6
+ @providers = []
7
+
8
+ class << self
9
+ attr_reader :providers
10
+
11
+ # Register a search provider (idempotent — overwrites any existing provider with the same name)
12
+ # @param name [String] Provider name (e.g., "pages", "posts")
13
+ # @param search_class [Class] Class that responds to .editor_search(query, limit:)
14
+ # and optionally .admin_search(query, limit:) for richer global search results
15
+ def register(name:, search_class:)
16
+ @providers.reject! { |p| p[:name] == name }
17
+ @providers << {name: name, search_class: search_class}
18
+ end
19
+
20
+ # Search all registered providers (editor format)
21
+ # @param query [String] Search query
22
+ # @param limit [Integer] Max results per provider
23
+ # @return [Array<Hash>] Array of { href:, name:, description: }
24
+ def search(query, limit: 5)
25
+ return [] if query.blank?
26
+
27
+ @providers.flat_map do |provider|
28
+ provider[:search_class].editor_search(query, limit: limit)
29
+ rescue => e
30
+ Rails.logger.warn("[panda-core] Search provider #{provider[:name]} failed: #{e.message}") if defined?(Rails.logger)
31
+ []
32
+ end
33
+ end
34
+
35
+ # Search all registered providers (admin format with grouping)
36
+ # @param query [String] Search query
37
+ # @param limit [Integer] Max results per provider
38
+ # @return [Hash] { groups: [{ name:, icon:, results: [{ href:, name:, description: }] }] }
39
+ def admin_search(query, limit: 5)
40
+ return {groups: []} if query.blank?
41
+
42
+ groups = @providers.filter_map do |provider|
43
+ klass = provider[:search_class]
44
+ next unless klass.respond_to?(:admin_search)
45
+
46
+ results = klass.admin_search(query, limit: limit)
47
+ next if results.empty?
48
+
49
+ config = klass.respond_to?(:searchable_config) ? klass.searchable_config : {}
50
+ {
51
+ name: config[:group] || provider[:name].to_s.titleize,
52
+ icon: config[:icon],
53
+ results: results
54
+ }
55
+ rescue => e
56
+ Rails.logger.warn("[panda-core] Admin search provider #{provider[:name]} failed: #{e.message}") if defined?(Rails.logger)
57
+ nil
58
+ end
59
+
60
+ {groups: groups}
61
+ end
62
+
63
+ def reset!
64
+ @providers = []
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ module Seeds
6
+ module FileCategories
7
+ DEFAULTS = [
8
+ {name: "Media Library", slug: "media-library", icon: "fa-solid fa-photo-film", position: 0},
9
+ {name: "Page Images", slug: "page-images", icon: "fa-solid fa-file-image", position: 1},
10
+ {name: "Post Images", slug: "post-images", icon: "fa-solid fa-newspaper", position: 2},
11
+ {name: "User Avatars", slug: "user-avatars", icon: "fa-solid fa-user-circle", position: 3},
12
+ {name: "Website Avatars", slug: "website-avatars", icon: "fa-solid fa-users", position: 4},
13
+ {name: "Form Uploads", slug: "form-uploads", icon: "fa-solid fa-file-arrow-up", position: 5},
14
+ {name: "Social Media", slug: "social-media", icon: "fab fa-instagram", position: 6}
15
+ ].freeze
16
+
17
+ def self.seed!
18
+ DEFAULTS.each do |attrs|
19
+ Panda::Core::FileCategory.find_or_create_by!(slug: attrs[:slug]) do |category|
20
+ category.name = attrs[:name]
21
+ category.icon = attrs[:icon]
22
+ category.position = attrs[:position]
23
+ category.system = true
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end