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
@@ -0,0 +1,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ module Admin
6
+ class FilesController < ::Panda::Core::Admin::BaseController
7
+ before_action :set_initial_breadcrumb, only: [:index]
8
+ before_action :set_blob, only: [:show, :update, :destroy]
9
+
10
+ def index
11
+ # Exclude variant blobs — they are attached to VariantRecord, not user-uploaded
12
+ variant_blob_ids = ActiveStorage::Attachment
13
+ .where(record_type: "ActiveStorage::VariantRecord")
14
+ .select(:blob_id)
15
+
16
+ @files = ActiveStorage::Blob
17
+ .where.not(id: variant_blob_ids)
18
+ .includes(:variant_records)
19
+ .order(created_at: :desc)
20
+
21
+ @file_categories = Panda::Core::FileCategory.ordered
22
+ @active_category = params[:category]
23
+
24
+ # Apply category filter
25
+ if @active_category == "uncategorized"
26
+ categorized_blob_ids = Panda::Core::FileCategorization.select(:blob_id)
27
+ @files = @files.where.not(id: categorized_blob_ids)
28
+ elsif @active_category.present?
29
+ category = Panda::Core::FileCategory.find_by(slug: @active_category)
30
+ if category
31
+ blob_ids = Panda::Core::FileCategorization.where(file_category: category).select(:blob_id)
32
+ @files = @files.where(id: blob_ids)
33
+ end
34
+ end
35
+
36
+ # Preload category names for gallery display
37
+ categorizations = Panda::Core::FileCategorization
38
+ .where(blob_id: @files.select(:id))
39
+ .includes(:file_category)
40
+ @blob_categories = categorizations.each_with_object({}) do |cat, hash|
41
+ hash[cat.blob_id] = cat.file_category
42
+ end
43
+ end
44
+
45
+ def show
46
+ @file_categories = Panda::Core::FileCategory.ordered
47
+
48
+ if request.xhr? || request.headers["Turbo-Frame"].present?
49
+ render partial: "file_details", locals: {file: @blob, file_categories: @file_categories}
50
+ else
51
+ redirect_to admin_files_path
52
+ end
53
+ end
54
+
55
+ def create
56
+ # JSON uploads from EditorJS
57
+ if params[:image].present?
58
+ return create_from_editor
59
+ end
60
+
61
+ # File uploads from EditorJS AttachesTool
62
+ if params[:file].present?
63
+ return create_file_from_editor
64
+ end
65
+
66
+ # HTML form uploads from the file gallery
67
+ file = params.dig(:file_upload, :file)
68
+ category_id = params.dig(:file_upload, :file_category_id)
69
+
70
+ if file.blank?
71
+ redirect_to admin_files_path, alert: "Please select a file to upload."
72
+ return
73
+ end
74
+
75
+ if category_id.blank?
76
+ redirect_to admin_files_path, alert: "Please select a category."
77
+ return
78
+ end
79
+
80
+ unless (category = Panda::Core::FileCategory.find_by(id: category_id))
81
+ redirect_to admin_files_path, alert: "Selected category not found."
82
+ return
83
+ end
84
+
85
+ blob = find_existing_blob(file) || ActiveStorage::Blob.create_and_upload!(
86
+ io: file,
87
+ filename: file.original_filename,
88
+ content_type: file.content_type
89
+ )
90
+
91
+ stamp_uploader(blob)
92
+
93
+ Panda::Core::FileCategorization.find_or_create_by!(file_category: category, blob: blob)
94
+
95
+ redirect_to admin_files_path, notice: "File uploaded successfully."
96
+ end
97
+
98
+ def update
99
+ blob_params = params.require(:blob).permit(:filename, :description, :file_category_id)
100
+
101
+ # Update filename — always re-append the original extension
102
+ new_filename = blob_params[:filename].to_s.strip
103
+ if new_filename.present?
104
+ original_ext = File.extname(@blob.filename.to_s)
105
+ new_filename += original_ext if File.extname(new_filename).blank? && original_ext.present?
106
+ @blob.filename = new_filename
107
+ end
108
+
109
+ # Update description in metadata
110
+ metadata = @blob.metadata || {}
111
+ metadata["description"] = blob_params[:description].to_s.strip
112
+ @blob.metadata = metadata
113
+
114
+ @blob.save!
115
+ update_file_category(blob_params[:file_category_id])
116
+
117
+ @file_categories = Panda::Core::FileCategory.ordered
118
+ locals = {file: @blob, file_categories: @file_categories, notice: "File updated successfully."}
119
+ respond_to do |format|
120
+ format.turbo_stream do
121
+ render turbo_stream: turbo_stream.update(
122
+ "file-gallery-slideover-content",
123
+ partial: "file_details",
124
+ locals: locals
125
+ )
126
+ end
127
+ format.html do
128
+ redirect_to admin_files_path, notice: "File updated successfully.", status: :see_other
129
+ end
130
+ end
131
+ end
132
+
133
+ def destroy
134
+ # Remove variant records and their blobs first (each variant has its own blob)
135
+ @blob.variant_records.includes(image_attachment: :blob).find_each do |vr|
136
+ vr.image.purge_later if vr.image.attached?
137
+ vr.destroy
138
+ end
139
+
140
+ # Remove remaining FK references before purging the blob
141
+ ActiveStorage::Attachment.where(blob_id: @blob.id).delete_all
142
+ Panda::Core::FileCategorization.where(blob_id: @blob.id).delete_all
143
+ @blob.purge_later
144
+
145
+ redirect_to admin_files_path, notice: "File was successfully deleted.", status: :see_other
146
+ end
147
+
148
+ private
149
+
150
+ def set_blob
151
+ @blob = ActiveStorage::Blob.find(params[:id])
152
+ end
153
+
154
+ def set_initial_breadcrumb
155
+ add_breadcrumb "Files", admin_files_path
156
+ end
157
+
158
+ def create_from_editor
159
+ file = params[:image]
160
+ return render json: {success: 0} unless file
161
+
162
+ blob = find_existing_blob(file) || ActiveStorage::Blob.create_and_upload!(
163
+ io: file,
164
+ filename: file.original_filename,
165
+ content_type: file.content_type
166
+ )
167
+
168
+ stamp_uploader(blob)
169
+
170
+ render json: {
171
+ success: true,
172
+ file: {
173
+ url: Rails.application.routes.url_helpers.rails_blob_url(blob, only_path: true),
174
+ name: blob.filename.to_s,
175
+ size: blob.byte_size,
176
+ signed_id: blob.signed_id
177
+ }
178
+ }
179
+ end
180
+
181
+ def create_file_from_editor
182
+ file = params[:file]
183
+ return render json: {success: 0} unless file
184
+
185
+ blob = find_existing_blob(file) || ActiveStorage::Blob.create_and_upload!(
186
+ io: file,
187
+ filename: file.original_filename,
188
+ content_type: file.content_type
189
+ )
190
+
191
+ stamp_uploader(blob)
192
+
193
+ extension = File.extname(blob.filename.to_s).delete_prefix(".")
194
+
195
+ render json: {
196
+ success: 1,
197
+ file: {
198
+ url: Rails.application.routes.url_helpers.rails_blob_url(blob, only_path: true),
199
+ name: blob.filename.to_s,
200
+ size: blob.byte_size,
201
+ extension: extension,
202
+ signed_id: blob.signed_id
203
+ }
204
+ }
205
+ end
206
+
207
+ def find_existing_blob(file)
208
+ checksum = Digest::MD5.file(file.tempfile.path).base64digest
209
+ ActiveStorage::Blob.find_by(checksum: checksum, byte_size: file.size)
210
+ end
211
+
212
+ def stamp_uploader(blob)
213
+ return unless current_user
214
+ metadata = blob.metadata || {}
215
+ metadata["uploaded_by_id"] = current_user.id
216
+ metadata["uploaded_by_name"] = current_user.name
217
+ blob.update_column(:metadata, metadata)
218
+ end
219
+
220
+ def update_file_category(category_id)
221
+ Panda::Core::FileCategorization.where(blob_id: @blob.id).destroy_all
222
+ return if category_id.blank?
223
+
224
+ category = Panda::Core::FileCategory.find_by(id: category_id)
225
+ Panda::Core::FileCategorization.create!(file_category: category, blob: @blob) if category
226
+ end
227
+ end
228
+ end
229
+ end
230
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ module Admin
6
+ class ImportSessionsController < BaseController
7
+ before_action :set_initial_breadcrumb
8
+ before_action :set_import_session, only: %i[show column_map update_mapping preview import]
9
+
10
+ def index
11
+ @import_sessions = scoped_import_sessions.recent
12
+ @import_sessions = @import_sessions.where(importable_type: params[:type]) if params[:type].present?
13
+ end
14
+
15
+ def new
16
+ @importable_type = params[:importable_type]
17
+ end
18
+
19
+ def create
20
+ tenant = resolve_tenant
21
+ uploaded_file = params[:import_file]
22
+
23
+ unless uploaded_file.present?
24
+ @importable_type = params[:importable_type]
25
+ flash.now[:error] = "Please select a file to upload."
26
+ render :new, status: :unprocessable_entity
27
+ return
28
+ end
29
+
30
+ if FileParser.xls?(uploaded_file.original_filename)
31
+ @importable_type = params[:importable_type]
32
+ flash.now[:error] = "XLS format is not supported. Please save your spreadsheet as XLSX, CSV, or TSV and try again."
33
+ render :new, status: :unprocessable_entity
34
+ return
35
+ end
36
+
37
+ unless FileParser.supported?(uploaded_file.original_filename)
38
+ @importable_type = params[:importable_type]
39
+ flash.now[:error] = "Unsupported file format. Please upload a CSV, TSV, or XLSX file."
40
+ render :new, status: :unprocessable_entity
41
+ return
42
+ end
43
+
44
+ @import_session = ImportSession.new(
45
+ importable_type: params[:importable_type],
46
+ user: current_user,
47
+ tenant: tenant,
48
+ status: "mapping"
49
+ )
50
+
51
+ if uploaded_file.present?
52
+ @import_session.import_file.attach(uploaded_file)
53
+ end
54
+
55
+ if @import_session.save
56
+ redirect_to column_map_admin_import_session_path(@import_session)
57
+ else
58
+ @importable_type = params[:importable_type]
59
+ render :new, status: :unprocessable_entity
60
+ end
61
+ end
62
+
63
+ def show
64
+ end
65
+
66
+ def column_map
67
+ @headers = @import_session.file_headers
68
+ @field_options = @import_session.column_options
69
+ end
70
+
71
+ def update_mapping
72
+ mapping = {}
73
+ (params[:mapping] || {}).each do |csv_col, field_name|
74
+ mapping[csv_col] = field_name if field_name.present?
75
+ end
76
+
77
+ @import_session.update!(column_mapping: mapping, status: "previewing")
78
+ redirect_to preview_admin_import_session_path(@import_session)
79
+ end
80
+
81
+ def preview
82
+ @preview_rows = @import_session.preview_rows(limit: 5)
83
+ @mapping = @import_session.column_mapping
84
+ @field_definitions = @import_session.column_options
85
+ end
86
+
87
+ def import
88
+ @import_session.update!(status: "importing")
89
+
90
+ # Run synchronously for now — swap to CSVImportJob.perform_later for async
91
+ CSVImportService.new(@import_session).call
92
+
93
+ redirect_to admin_import_session_path(@import_session), success: "Import complete."
94
+ rescue => e
95
+ redirect_to admin_import_session_path(@import_session), error: "Import failed: #{e.message}"
96
+ end
97
+
98
+ private
99
+
100
+ def set_import_session
101
+ @import_session = scoped_import_sessions.find(params[:id])
102
+ end
103
+
104
+ def set_initial_breadcrumb
105
+ add_breadcrumb "Import", admin_import_sessions_path
106
+ end
107
+
108
+ def scoped_import_sessions
109
+ tenant = resolve_tenant
110
+ tenant ? ImportSession.where(tenant: tenant) : ImportSession.where(tenant_type: nil)
111
+ end
112
+
113
+ def resolve_tenant
114
+ return ActsAsTenant.current_tenant if defined?(ActsAsTenant) && ActsAsTenant.current_tenant
115
+ nil
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ module Admin
6
+ module MyProfile
7
+ class LoginsController < BaseController
8
+ before_action :set_initial_breadcrumb
9
+
10
+ def show
11
+ render :show, locals: {user: current_user}
12
+ end
13
+
14
+ private
15
+
16
+ def set_initial_breadcrumb
17
+ add_breadcrumb "My Profile", edit_admin_my_profile_path
18
+ add_breadcrumb "Login & Security", admin_my_profile_logins_path
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -24,7 +24,11 @@ module Panda
24
24
  # @type PATCH/PUT
25
25
  # @return void
26
26
  def update
27
+ purge_old_avatar_if_replacing
28
+
27
29
  if current_user.update(user_params)
30
+ rename_avatar_blob
31
+ clear_oauth_avatar_url_after_manual_upload
28
32
  flash[:success] = "Your profile has been updated successfully."
29
33
  redirect_to edit_admin_my_profile_path
30
34
  else
@@ -38,6 +42,33 @@ module Panda
38
42
  add_breadcrumb "My Profile", edit_admin_my_profile_path
39
43
  end
40
44
 
45
+ # Purge the existing avatar synchronously when a replacement is being uploaded.
46
+ # This ensures the old blob is fully removed (DB + storage) before the new
47
+ # one is attached, preventing orphaned blobs that show as "in use".
48
+ def purge_old_avatar_if_replacing
49
+ return unless params.dig(:user, :avatar).present? && current_user.avatar.attached?
50
+ current_user.avatar.purge
51
+ end
52
+
53
+ # Rename the avatar blob to a consistent "avatar-full-name.ext" format
54
+ # so it's identifiable in the file gallery.
55
+ def rename_avatar_blob
56
+ upload = params.dig(:user, :avatar)
57
+ return unless upload.present? && current_user.avatar.attached?
58
+
59
+ extension = File.extname(upload.original_filename)
60
+ new_filename = "avatar-#{current_user.name.parameterize}#{extension}"
61
+ current_user.avatar.blob.update!(filename: new_filename)
62
+ end
63
+
64
+ # Clear oauth_avatar_url when user manually uploads an avatar.
65
+ # This signals to find_or_create_from_auth_hash that the avatar
66
+ # was manually uploaded and should not be overwritten on OAuth re-login.
67
+ def clear_oauth_avatar_url_after_manual_upload
68
+ return unless params.dig(:user, :avatar).present?
69
+ current_user.update_column(:oauth_avatar_url, nil)
70
+ end
71
+
41
72
  # Only allow a list of trusted parameters through
42
73
  # @type private
43
74
  # @return ActionController::StrongParameters
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ module Admin
6
+ class SearchController < ::Panda::Core::Admin::BaseController
7
+ def index
8
+ query = params[:q].to_s.strip
9
+ results = Panda::Core::SearchRegistry.admin_search(query, limit: 5)
10
+ render json: results
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -10,9 +10,14 @@ module Panda
10
10
  skip_before_action :authenticate_admin_user!, only: [:new, :create, :destroy, :failure]
11
11
 
12
12
  def new
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?
13
+ providers = Core.config.resolved_authentication_providers(request)
14
+ @providers = providers.select do |key, config|
15
+ # Developer provider doesn't need credentials, only shown in development
16
+ if key.to_sym == :developer
17
+ Rails.env.development?
18
+ else
19
+ config[:client_id].present? && config[:client_secret].present?
20
+ end
16
21
  end.keys
17
22
  end
18
23
 
@@ -23,7 +28,8 @@ module Panda
23
28
  # Find the actual provider key (might be using path_name override)
24
29
  provider = find_provider_by_path(provider_path)
25
30
 
26
- unless provider && Core.config.authentication_providers.key?(provider)
31
+ resolved = Core.config.resolved_authentication_providers(request)
32
+ unless provider && resolved.key?(provider)
27
33
  redirect_to admin_login_path, flash: {error: "Authentication provider not enabled"}
28
34
  return
29
35
  end
@@ -31,21 +37,69 @@ module Panda
31
37
  user = User.find_or_create_from_auth_hash(auth)
32
38
 
33
39
  if user.persisted?
34
- # Check if user is admin before allowing access
35
- unless user.admin?
40
+ # Per-login validation hook (e.g. email domain restrictions)
41
+ if Core.config.authentication_validator
42
+ validation_error = Core.config.authentication_validator.call(user, auth, request)
43
+ if validation_error
44
+ redirect_to admin_login_path, flash: {error: validation_error}
45
+ return
46
+ end
47
+ end
48
+
49
+ # Check if user is authorized to access the admin area.
50
+ # Admin users always have access. Non-admin users are checked
51
+ # via the configurable authorization_policy (e.g. role-based access
52
+ # from panda-cms-pro).
53
+ policy = Panda::Core.config.authorization_policy
54
+ unless user.admin? || policy.call(user, :access_admin, nil)
36
55
  flash[:error] = "You do not have permission to access the admin area"
37
56
  flash.keep(:error) if Rails.env.test?
38
57
  redirect_to admin_login_path
39
58
  return
40
59
  end
41
60
 
42
- session[:user_id] = user.id
61
+ session[Panda::Core::ADMIN_SESSION_KEY] = user.id
43
62
  Panda::Core::Current.user = user
44
63
 
64
+ user.track_login!(request) if user.respond_to?(:track_login!)
65
+ Panda::Core::UserActivity.log!(user: user, action: "login", request: request) if defined?(Panda::Core::UserActivity)
66
+
45
67
  ActiveSupport::Notifications.instrument("panda.core.user_login",
46
68
  user: user,
47
69
  provider: provider)
48
70
 
71
+ # Consume stored redirect path once so it is cleared regardless of
72
+ # which redirect branch is taken below.
73
+ post_auth_path = session.delete(:post_auth_redirect_path)
74
+
75
+ # Post-authentication redirect hook (e.g. workspace picker).
76
+ # Wrapped in its own rescue so a broken hook falls through to the
77
+ # default redirect rather than hitting the outer rescue (which
78
+ # would redirect to login with the session already created).
79
+ if (redirect_proc = Panda::Core.config.post_authentication_redirect)
80
+ begin
81
+ redirect_url = redirect_proc.call(user, request)
82
+ if redirect_url.present?
83
+ redirect_to append_admin_path_suffix(redirect_url, post_auth_path), allow_other_host: true, flash: {success: "Successfully logged in as #{user.name}"}
84
+ return
85
+ end
86
+ rescue => e
87
+ Rails.logger.error "post_authentication_redirect hook failed: #{e.class}: #{e.message}"
88
+ end
89
+ end
90
+
91
+ # Redirect to originating subdomain if auth callback landed on root domain
92
+ origin = session.delete(:origin_subdomain)
93
+ if origin.present? && request.host.split(".").first != origin
94
+ scheme = request.scheme
95
+ base_domain = request.host.sub(/\A[^.]+\./, "")
96
+ port = request.port
97
+ port_suffix = [80, 443].include?(port) ? "" : ":#{port}"
98
+ base_url = "#{scheme}://#{origin}.#{base_domain}#{port_suffix}#{Core.config.admin_path}"
99
+ redirect_to append_admin_path_suffix(base_url, post_auth_path), allow_other_host: true, flash: {success: "Successfully logged in as #{user.name}"}
100
+ return
101
+ end
102
+
49
103
  # Use configured dashboard path or default to admin_root_path
50
104
  redirect_path = Panda::Core.config.dashboard_redirect_path || admin_root_path
51
105
  redirect_path = redirect_path.call if redirect_path.respond_to?(:call)
@@ -69,7 +123,7 @@ module Panda
69
123
  end
70
124
 
71
125
  def destroy
72
- session.delete(:user_id)
126
+ session.delete(Panda::Core::ADMIN_SESSION_KEY)
73
127
  Panda::Core::Current.user = nil
74
128
 
75
129
  ActiveSupport::Notifications.instrument("panda.core.user_logout")
@@ -79,13 +133,33 @@ module Panda
79
133
 
80
134
  private
81
135
 
136
+ # Append the path segment that follows the admin prefix from +stored_path+
137
+ # to +base_url+. Returns +base_url+ unchanged when there is no meaningful
138
+ # suffix (e.g. no stored path, or the stored path is the admin root itself).
139
+ #
140
+ # Example: base_url="https://workspace.example.com/admin", stored_path="/admin/users/123"
141
+ # → "https://workspace.example.com/admin/users/123"
142
+ def append_admin_path_suffix(base_url, stored_path)
143
+ return base_url if stored_path.blank?
144
+
145
+ admin_prefix = Core.config.admin_path
146
+ return base_url unless stored_path.start_with?(admin_prefix)
147
+
148
+ suffix = stored_path[admin_prefix.length..]
149
+ return base_url if suffix.blank? || suffix == "/"
150
+
151
+ base_url.chomp("/") + suffix
152
+ end
153
+
82
154
  # Find the provider key by path name (handles path_name override)
83
155
  def find_provider_by_path(provider_path)
156
+ providers = Core.config.resolved_authentication_providers(request)
157
+
84
158
  # First check if it's a direct match
85
- return provider_path if Core.config.authentication_providers.key?(provider_path)
159
+ return provider_path if providers.key?(provider_path)
86
160
 
87
161
  # Then check if any provider has a matching path_name
88
- Core.config.authentication_providers.each do |key, config|
162
+ providers.each do |key, config|
89
163
  return key if config[:path_name]&.to_sym == provider_path
90
164
  end
91
165
 
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ module Admin
6
+ class TagsController < BaseController
7
+ before_action :set_initial_breadcrumb
8
+ before_action :set_tag, only: %i[edit update destroy]
9
+
10
+ def index
11
+ tenant = resolve_tenant
12
+ @tags = Tag.for_tenant(tenant).ordered
13
+
14
+ if params[:q].present?
15
+ @tags = @tags.search_by_name(params[:q])
16
+ end
17
+
18
+ respond_to do |format|
19
+ format.html
20
+ format.json { render json: @tags.limit(10).map { |t| {id: t.id, name: t.name, colour: t.display_colour} } }
21
+ end
22
+ end
23
+
24
+ def new
25
+ @tag = Tag.new
26
+ end
27
+
28
+ def create
29
+ tenant = resolve_tenant
30
+ @tag = Tag.new(tag_params.merge(tenant: tenant))
31
+
32
+ if @tag.save
33
+ redirect_to admin_tags_path, success: "Tag created."
34
+ else
35
+ render :new, status: :unprocessable_entity
36
+ end
37
+ end
38
+
39
+ def edit
40
+ end
41
+
42
+ def update
43
+ if @tag.update(tag_params)
44
+ redirect_to admin_tags_path, success: "Tag updated."
45
+ else
46
+ render :edit, status: :unprocessable_entity
47
+ end
48
+ end
49
+
50
+ def destroy
51
+ @tag.destroy
52
+ redirect_to admin_tags_path, success: "Tag deleted."
53
+ end
54
+
55
+ private
56
+
57
+ def set_tag
58
+ @tag = Tag.for_tenant(resolve_tenant).find(params[:id])
59
+ end
60
+
61
+ def tag_params
62
+ params.require(:tag).permit(:name, :colour)
63
+ end
64
+
65
+ def set_initial_breadcrumb
66
+ add_breadcrumb "Settings", admin_settings_path
67
+ add_breadcrumb "Tags", admin_tags_path
68
+ end
69
+
70
+ def resolve_tenant
71
+ return ActsAsTenant.current_tenant if defined?(ActsAsTenant) && ActsAsTenant.current_tenant
72
+ nil
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -18,19 +18,18 @@ module Panda
18
18
  def create
19
19
  user = Panda::Core::User.find(params[:user_id])
20
20
 
21
- # Check if user is admin (mimics real OAuth behavior)
22
- unless user.admin?
23
- # Non-admin users are redirected to login with error (mimics real OAuth flow)
21
+ # Mirror real SessionsController: admin users always pass,
22
+ # non-admin users checked via authorization_policy
23
+ policy = Panda::Core.config.authorization_policy
24
+ unless user.admin? || policy.call(user, :access_admin, nil)
24
25
  flash[:alert] = "You do not have permission to access the admin area."
25
- # Keep flash for one more request to survive redirect in tests
26
26
  flash.keep(:alert) if Rails.env.test?
27
- # Use string path since route helpers aren't available in ActionController::Base
28
27
  redirect_to "#{Panda::Core.config.admin_path || "/admin"}/login", allow_other_host: false, status: :found
29
28
  return
30
29
  end
31
30
 
32
31
  # Set session (mimics real OAuth callback)
33
- session[:user_id] = user.id
32
+ session[Panda::Core::ADMIN_SESSION_KEY] = user.id
34
33
  Panda::Core::Current.user = user
35
34
 
36
35
  # Support custom redirect path for test flexibility