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
@@ -4,9 +4,19 @@ module Panda
4
4
  module Core
5
5
  class User < ApplicationRecord
6
6
  include HasUUID
7
+ include HasMetadata
8
+
9
+ metadata_field :internal, type: :boolean, filterable: true,
10
+ label: "Visibility", default_scope: :external,
11
+ filter_options: [["All Users", ""], ["Staff Users", "internal"], ["External Users", "external"]]
7
12
 
8
13
  self.table_name = "panda_core_users"
9
14
 
15
+ # Associations
16
+ has_many :user_activities, class_name: "Panda::Core::UserActivity", dependent: :destroy
17
+ has_many :user_sessions, class_name: "Panda::Core::UserSession", dependent: :destroy
18
+ belongs_to :invited_by, class_name: "Panda::Core::User", optional: true
19
+
10
20
  # Active Storage attachment for avatar with variants
11
21
  has_one_attached :avatar do |attachable|
12
22
  attachable.variant :thumb, resize_to_limit: [50, 50], preprocessed: true
@@ -29,30 +39,62 @@ module Panda
29
39
  scope :admins, -> {
30
40
  where(admin_column => true)
31
41
  }
42
+ scope :enabled, -> { where(enabled: true) }
43
+ scope :disabled, -> { where(enabled: false) }
44
+ scope :invited, -> { where.not(invitation_token: nil).where(invitation_accepted_at: nil) }
45
+ scope :active_recently, -> { where(last_login_at: 30.days.ago..) }
46
+ scope :search, ->(query) {
47
+ return all if query.blank?
48
+ where("name ILIKE :q OR email ILIKE :q", q: "%#{sanitize_sql_like(query)}%")
49
+ }
32
50
 
33
51
  def self.find_or_create_from_auth_hash(auth_hash)
34
52
  user = find_by(email: auth_hash.info.email.downcase)
35
53
 
36
- # Handle avatar for both new and existing users
37
54
  avatar_url = auth_hash.info.image
38
55
  if user
39
- # Update avatar if URL has changed or no avatar is attached
40
- if avatar_url.present? && (avatar_url != user.oauth_avatar_url || !user.avatar.attached?)
41
- AttachAvatarService.call(user: user, avatar_url: avatar_url)
56
+ has_stored_avatar = begin
57
+ user.avatar.attached?
58
+ rescue
59
+ false
60
+ end
61
+
62
+ if avatar_url.present?
63
+ # Update image_url with latest OAuth URL only if no local avatar is stored
64
+ user.update_column(:image_url, avatar_url) unless has_stored_avatar
65
+
66
+ # Skip OAuth avatar download when user has a manually uploaded avatar
67
+ # (indicated by oauth_avatar_url being nil while an avatar is attached)
68
+ manually_uploaded = has_stored_avatar && user.oauth_avatar_url.nil?
69
+ unless manually_uploaded
70
+ # Try to download and store avatar if URL changed or no avatar attached
71
+ if avatar_url != user.oauth_avatar_url || !has_stored_avatar
72
+ AttachAvatarService.call(user: user, avatar_url: avatar_url)
73
+ end
74
+ end
42
75
  end
43
76
  return user
44
77
  end
45
78
 
79
+ # Check if user creation is restricted (e.g. invite-only mode)
80
+ restrict = Panda::Core.config.restrict_user_creation
81
+ restricted = restrict.respond_to?(:call) ? restrict.call(auth_hash) : restrict
82
+ if restricted
83
+ user = new(email: auth_hash.info.email.downcase, name: auth_hash.info.name || "Unknown User")
84
+ user.errors.add(:base, "No account exists for this email address. Please contact your administrator.")
85
+ return user
86
+ end
87
+
46
88
  attributes = {
47
89
  :email => auth_hash.info.email.downcase,
48
90
  :name => auth_hash.info.name || "Unknown User",
49
- :image_url => auth_hash.info.image,
91
+ :image_url => avatar_url,
50
92
  admin_column => User.count.zero? # First user is admin
51
93
  }
52
94
 
53
95
  user = create!(attributes)
54
96
 
55
- # Attach avatar for new user
97
+ # Attach avatar for new user (will clear image_url on success)
56
98
  if avatar_url.present?
57
99
  AttachAvatarService.call(user: user, avatar_url: avatar_url)
58
100
  end
@@ -77,7 +119,42 @@ module Panda
77
119
  alias_method :is_admin=, :admin=
78
120
 
79
121
  def active_for_authentication?
80
- true
122
+ enabled?
123
+ end
124
+
125
+ def enable!
126
+ update!(enabled: true)
127
+ end
128
+
129
+ def disable!
130
+ update!(enabled: false)
131
+ end
132
+
133
+ def enabled?
134
+ self[:enabled] != false
135
+ end
136
+
137
+ def invite!(invited_by:)
138
+ update!(
139
+ invitation_token: SecureRandom.urlsafe_base64(32),
140
+ invitation_sent_at: Time.current,
141
+ invited_by: invited_by
142
+ )
143
+ end
144
+
145
+ def accept_invitation!
146
+ update!(
147
+ invitation_accepted_at: Time.current,
148
+ invitation_token: nil
149
+ )
150
+ end
151
+
152
+ def track_login!(request)
153
+ update!(
154
+ last_login_at: Time.current,
155
+ last_login_ip: request.remote_ip,
156
+ login_count: (login_count || 0) + 1
157
+ )
81
158
  end
82
159
 
83
160
  # Returns the URL for the user's avatar
@@ -85,16 +162,17 @@ module Panda
85
162
  # @param size [Symbol] The variant size (:thumb, :small, :medium, :large, or nil for original)
86
163
  # @return [String, nil] The avatar URL or nil if no avatar available
87
164
  def avatar_url(size: nil)
88
- if avatar.attached?
89
- if size && [:thumb, :small, :medium, :large].include?(size)
90
- Rails.application.routes.url_helpers.rails_blob_path(avatar.variant(size), only_path: true)
91
- else
92
- Rails.application.routes.url_helpers.rails_blob_path(avatar, only_path: true)
93
- end
94
- elsif self[:image_url].present?
95
- # Fallback to OAuth provider URL if no avatar is attached yet
96
- self[:image_url]
165
+ return self[:image_url].presence unless avatar.attached?
166
+
167
+ helpers = Rails.application.routes.url_helpers
168
+ if size && [:thumb, :small, :medium, :large].include?(size)
169
+ helpers.rails_representation_path(avatar.variant(size), only_path: true)
170
+ else
171
+ helpers.rails_blob_path(avatar.blob, only_path: true)
97
172
  end
173
+ rescue => e
174
+ Rails.logger.error("Error generating avatar URL for user #{id}: #{e.message}\n#{e.backtrace&.first(3)&.join("\n")}")
175
+ self[:image_url].presence
98
176
  end
99
177
 
100
178
  private
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ class UserActivity < ApplicationRecord
6
+ include HasUUID
7
+
8
+ self.table_name = "panda_core_user_activities"
9
+
10
+ belongs_to :user, class_name: "Panda::Core::User"
11
+
12
+ validates :action, presence: true
13
+
14
+ scope :recent, -> { order(created_at: :desc) }
15
+ scope :for_user, ->(user) { where(user: user) }
16
+ scope :by_action, ->(action) { where(action: action) }
17
+ scope :today, -> { where(created_at: Time.current.beginning_of_day..) }
18
+ scope :this_week, -> { where(created_at: Time.current.beginning_of_week..) }
19
+
20
+ # Log a user activity with optional request context
21
+ def self.log!(user:, action:, resource: nil, metadata: {}, request: nil)
22
+ attrs = {
23
+ user: user,
24
+ action: action.to_s,
25
+ metadata: metadata
26
+ }
27
+
28
+ if resource
29
+ attrs[:resource_type] = resource.class.name
30
+ attrs[:resource_id] = resource.id
31
+ end
32
+
33
+ if request
34
+ attrs[:ip_address] = request.remote_ip
35
+ attrs[:user_agent] = request.user_agent
36
+ end
37
+
38
+ create!(attrs)
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ class UserSession < ApplicationRecord
6
+ include HasUUID
7
+
8
+ self.table_name = "panda_core_user_sessions"
9
+
10
+ belongs_to :user, class_name: "Panda::Core::User"
11
+ belongs_to :revoked_by, class_name: "Panda::Core::User", optional: true
12
+
13
+ validates :session_id, presence: true, uniqueness: true
14
+
15
+ scope :active_sessions, -> { where(active: true, revoked_at: nil) }
16
+ scope :revoked_sessions, -> { where(active: false) }
17
+ scope :for_user, ->(user) { where(user: user) }
18
+ scope :recent, -> { order(last_active_at: :desc) }
19
+
20
+ # Revoke a session (e.g. admin force-logout)
21
+ def revoke!(admin:)
22
+ update!(
23
+ active: false,
24
+ revoked_at: Time.current,
25
+ revoked_by: admin
26
+ )
27
+ end
28
+
29
+ # Update last activity timestamp
30
+ def touch_activity!
31
+ update!(last_active_at: Time.current)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -15,11 +15,19 @@ module Panda
15
15
 
16
16
  def call
17
17
  return success if @avatar_url.blank?
18
- return success if @avatar_url == @user.oauth_avatar_url && @user.avatar.attached?
18
+
19
+ avatar_attached = begin
20
+ @user.avatar.attached?
21
+ rescue
22
+ false
23
+ end
24
+ return success if @avatar_url == @user.oauth_avatar_url && avatar_attached
19
25
 
20
26
  begin
21
27
  download_and_attach_avatar
22
- @user.update_column(:oauth_avatar_url, @avatar_url)
28
+ # Record which OAuth URL was downloaded and clear image_url since
29
+ # we now have a local ActiveStorage copy
30
+ @user.update_columns(oauth_avatar_url: @avatar_url, image_url: nil)
23
31
  success(avatar_attached: true)
24
32
  rescue => e
25
33
  Rails.logger.error("Failed to attach avatar for user #{@user.id}: #{e.message}")
@@ -48,7 +56,11 @@ module Panda
48
56
 
49
57
  filename = "avatar_#{@user.id}_#{Time.current.to_i}.webp"
50
58
 
51
- # Attach the optimized avatar
59
+ # Purge any existing avatar first to prevent duplicate attachment records.
60
+ # has_one_attached should auto-purge, but explicit purge guards against
61
+ # race conditions and failed async purge jobs leaving orphaned records.
62
+ @user.avatar.purge if @user.avatar.attached?
63
+
52
64
  @user.avatar.attach(
53
65
  io: optimized_file,
54
66
  filename: filename,
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ class CSVImportService
6
+ attr_reader :import_session
7
+
8
+ def initialize(import_session)
9
+ @import_session = import_session
10
+ end
11
+
12
+ def call
13
+ import_session.update!(status: "importing", started_at: Time.current)
14
+ rows = import_session.file_rows
15
+ import_session.update!(total_rows: rows.size)
16
+
17
+ klass = import_session.importable_class
18
+ mapping = import_session.column_mapping
19
+ tenant_attrs = build_tenant_attrs
20
+
21
+ rows.each_with_index do |row, index|
22
+ process_row(klass, row, mapping, tenant_attrs, index + 2) # +2 for header + 1-based
23
+ end
24
+
25
+ import_session.update!(status: "complete", completed_at: Time.current)
26
+ rescue => e
27
+ import_session.update!(status: "failed", completed_at: Time.current)
28
+ Rails.logger.error("[CSVImportService] Import #{import_session.id} failed: #{e.message}")
29
+ raise
30
+ end
31
+
32
+ private
33
+
34
+ def process_row(klass, row, mapping, tenant_attrs, row_number)
35
+ attrs, parse_errors = klass.import_row(row, mapping)
36
+
37
+ if parse_errors.any?
38
+ log_error(row_number, parse_errors)
39
+ import_session.increment!(:error_count)
40
+ else
41
+ record = klass.new(attrs.merge(tenant_attrs))
42
+ if record.save
43
+ import_session.increment!(:imported_count)
44
+ else
45
+ log_error(row_number, record.errors.full_messages)
46
+ import_session.increment!(:error_count)
47
+ end
48
+ end
49
+
50
+ import_session.increment!(:processed_rows)
51
+ end
52
+
53
+ def log_error(row_number, errors)
54
+ log = import_session.errors_log || []
55
+ log << {row: row_number, errors: errors}
56
+ import_session.update_column(:errors_log, log)
57
+ end
58
+
59
+ def build_tenant_attrs
60
+ return {} unless import_session.tenant
61
+ {tenant: import_session.tenant}
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ class FileCategorizer
6
+ # Maps [record_type, attachment_name] to category slug
7
+ ATTACHMENT_CATEGORY_MAP = {
8
+ ["Panda::Core::User", "avatar"] => "user-avatars",
9
+ ["Panda::CMS::Page", "og_image"] => "page-images",
10
+ ["Panda::CMS::Post", "og_image"] => "post-images",
11
+ ["Panda::CMS::FormSubmission", "files"] => "form-uploads",
12
+ ["Panda::Social::InstagramPost", "image"] => "social-media"
13
+ }.freeze
14
+
15
+ # Categorize a blob by looking up its attachment's record type and name
16
+ def categorize_attachment(attachment)
17
+ slug = slug_for_attachment(attachment)
18
+ return unless slug
19
+
20
+ categorize_blob(attachment.blob, category_slug: slug)
21
+ end
22
+
23
+ # Directly assign a blob to a category by slug
24
+ def categorize_blob(blob, category_slug:)
25
+ category = Panda::Core::FileCategory.find_by(slug: category_slug)
26
+ return unless category
27
+
28
+ Panda::Core::FileCategorization.find_or_create_by!(
29
+ file_category: category,
30
+ blob_id: blob.id
31
+ )
32
+ end
33
+
34
+ # Register additional mappings from other engines (e.g. panda-cms-pro)
35
+ def self.register_mapping(record_type, attachment_name, category_slug)
36
+ @custom_mappings ||= {}
37
+ @custom_mappings[[record_type, attachment_name]] = category_slug
38
+ end
39
+
40
+ def self.custom_mappings
41
+ @custom_mappings || {}
42
+ end
43
+
44
+ private
45
+
46
+ def slug_for_attachment(attachment)
47
+ key = [attachment.record_type, attachment.name]
48
+ ATTACHMENT_CATEGORY_MAP[key] || self.class.custom_mappings[key]
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+ require "stringio"
5
+
6
+ module Panda
7
+ module Core
8
+ class FileParser
9
+ class UnsupportedFormatError < StandardError; end
10
+
11
+ SUPPORTED_EXTENSIONS = %w[.csv .tsv .xlsx].freeze
12
+
13
+ def initialize(filename, content)
14
+ @filename = filename
15
+ @content = content
16
+ @extension = File.extname(filename).downcase
17
+ end
18
+
19
+ def headers
20
+ @headers ||= parse.first
21
+ end
22
+
23
+ def rows
24
+ @rows ||= parse.last
25
+ end
26
+
27
+ def self.supported?(filename)
28
+ ext = File.extname(filename).downcase
29
+ SUPPORTED_EXTENSIONS.include?(ext)
30
+ end
31
+
32
+ def self.xls?(filename)
33
+ File.extname(filename).downcase == ".xls"
34
+ end
35
+
36
+ private
37
+
38
+ def parse
39
+ @parsed ||= case @extension
40
+ when ".csv"
41
+ parse_csv
42
+ when ".tsv"
43
+ parse_tsv
44
+ when ".xlsx"
45
+ parse_xlsx
46
+ else
47
+ raise UnsupportedFormatError, "Unsupported file format: #{@extension}"
48
+ end
49
+ end
50
+
51
+ def parse_csv
52
+ parsed = CSV.parse(@content, headers: true)
53
+ headers = parsed.headers
54
+ rows = parsed.map(&:to_h)
55
+ [headers, rows]
56
+ end
57
+
58
+ def parse_tsv
59
+ parsed = CSV.parse(@content, headers: true, col_sep: "\t")
60
+ headers = parsed.headers
61
+ rows = parsed.map(&:to_h)
62
+ [headers, rows]
63
+ end
64
+
65
+ def parse_xlsx
66
+ require "xsv"
67
+
68
+ workbook = Xsv.open(StringIO.new(@content))
69
+ sheet = workbook.sheets.first
70
+ sheet.parse_headers!
71
+
72
+ [sheet.headers, sheet.to_a]
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ class InviteUserService < Services::BaseService
6
+ def initialize(email:, name:, invited_by:, admin: false)
7
+ @email = email.to_s.downcase.strip
8
+ @name = name.to_s.strip
9
+ @invited_by = invited_by
10
+ @admin = admin
11
+ end
12
+
13
+ def call
14
+ return failure(["Email is required"]) if @email.blank?
15
+ return failure(["Name is required"]) if @name.blank?
16
+
17
+ existing_user = User.find_by(email: @email)
18
+ return failure(["A user with this email already exists"]) if existing_user
19
+
20
+ user = User.new(
21
+ email: @email,
22
+ name: @name,
23
+ admin: @admin,
24
+ enabled: true
25
+ )
26
+
27
+ unless user.save
28
+ return failure(user.errors.full_messages)
29
+ end
30
+
31
+ user.invite!(invited_by: @invited_by)
32
+
33
+ UserActivity.log!(
34
+ user: @invited_by,
35
+ action: "invited_user",
36
+ resource: user,
37
+ metadata: {invited_email: @email, admin: @admin}
38
+ )
39
+
40
+ success(user: user)
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ class PresenceService
6
+ def self.record_presence(resource, user_id)
7
+ attrs = {
8
+ presenceable_type: resource.class.name,
9
+ presenceable_id: resource.id,
10
+ user_id: user_id
11
+ }
12
+
13
+ result = Presence.upsert(
14
+ attrs.merge(last_seen_at: Time.current),
15
+ unique_by: %i[presenceable_type presenceable_id user_id],
16
+ returning: %w[id]
17
+ )
18
+
19
+ record_id = result.first&.fetch("id") { raise "Upsert did not return an ID" }
20
+ Presence.find(record_id)
21
+ end
22
+
23
+ def self.remove_presence(resource, user_id)
24
+ Presence.where(
25
+ presenceable_type: resource.class.name,
26
+ presenceable_id: resource.id,
27
+ user_id: user_id
28
+ ).delete_all
29
+ end
30
+
31
+ def self.current_editors(resource)
32
+ Presence
33
+ .for_resource(resource)
34
+ .active(Presence::PRESENCE_TTL)
35
+ .includes(:user)
36
+ .map do |presence|
37
+ {
38
+ user_id: presence.user_id,
39
+ user_name: presence.user.name.presence || presence.user.email
40
+ }
41
+ end
42
+ end
43
+
44
+ def self.cleanup_stale!
45
+ Presence.stale(Presence::PRESENCE_TTL).delete_all
46
+ end
47
+ end
48
+ end
49
+ end
@@ -1,76 +1,6 @@
1
- <%= render "panda/core/shared/header", html_class: "h-full bg-white" %>
2
- <div class="flex h-full" id="panda-container">
3
- <div class="absolute top-0 w-full lg:flex lg:fixed lg:inset-y-0 lg:z-50 lg:flex-col lg:w-72">
4
- <div class="flex overflow-y-auto flex-col gap-y-5 px-4 pb-4 max-h-16 bg-gradient-admin lg:max-h-full grow" data-transition-enter="transition-all ease-in-out duration-300" data-transition-enter-from="m-h-16" data-transition-enter-to="max-h-full" data-transition-leave="transition-all ease-in-out duration-300" data-transition-leave-from="max-h-full" data-transition-leave-to="max-h-16">
5
- <%= render "panda/core/admin/shared/sidebar" %>
6
- </div>
7
- </div>
8
- <div class="flex flex-col flex-1 mt-16 ml-0 lg:mt-0 lg:ml-72" id="panda-inner-container" <% if content_for :sidebar %> data-controller="toggle" data-action="keydown.esc@window->toggle#hide keydown.escape@window->toggle#hide" tabindex="-1"<% end %>>
9
- <section id="panda-main" class="flex flex-row h-full">
10
- <div class="flex-1 h-full" id="panda-primary-content">
11
- <%= render "panda/core/admin/shared/breadcrumbs" %>
12
- <%= render "panda/core/admin/shared/flash" %>
13
- <%= yield %>
14
- </div>
15
- <% if content_for :sidebar %>
16
- <!-- Backdrop overlay -->
17
- <div data-toggle-target="toggleable" class="hidden fixed inset-0 bg-gray-500/75 transition-opacity duration-500 ease-in-out dark:bg-gray-900/50 z-40"
18
- data-transition-enter="transition-opacity duration-500 ease-in-out"
19
- data-transition-enter-from="opacity-0"
20
- data-transition-enter-to="opacity-100"
21
- data-transition-leave="transition-opacity duration-500 ease-in-out"
22
- data-transition-leave-from="opacity-100"
23
- data-transition-leave-to="opacity-0"></div>
24
-
25
- <!-- Slideover panel -->
26
- <div data-toggle-target="toggleable" class="hidden ml-auto block size-full max-w-md transform fixed right-0 top-0 h-full z-50"
27
- data-transition-enter="transform transition ease-in-out duration-500 sm:duration-700"
28
- data-transition-enter-from="translate-x-full"
29
- data-transition-enter-to="translate-x-0"
30
- data-transition-leave="transform transition ease-in-out duration-500 sm:duration-700"
31
- data-transition-leave-from="translate-x-0"
32
- data-transition-leave-to="translate-x-full"
33
- id="slideover">
34
- <div class="relative flex h-full flex-col bg-white shadow-xl dark:bg-gray-800 dark:after:absolute dark:after:inset-y-0 dark:after:left-0 dark:after:w-px dark:after:bg-white/10">
35
- <!-- Header -->
36
- <div class="bg-gradient-admin px-4 py-3 sm:px-6">
37
- <div class="flex items-center justify-between">
38
- <h2 class="text-base font-semibold text-white" id="slideover-title"><%= yield :sidebar_title %></h2>
39
- <div class="ml-3 flex items-center">
40
- <button type="button" data-action="click->toggle#toggle touch->toggle#toggle" class="flex items-center gap-x-2 rounded-md px-3 py-1.5 text-white bg-white/10 hover:bg-white/20 transition-colors border border-white/20">
41
- <span class="text-sm font-semibold uppercase tracking-wide">Close</span>
42
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true" class="size-5">
43
- <path d="M6 18 18 6M6 6l12 12" stroke-linecap="round" stroke-linejoin="round" />
44
- </svg>
45
- </button>
46
- </div>
47
- </div>
48
- </div>
49
-
50
- <!-- Content -->
51
- <div class="flex-1 overflow-y-auto">
52
- <%= yield :sidebar %>
53
- </div>
54
-
55
- <!-- Footer (if present) -->
56
- <% if content_for?(:sidebar_footer) %>
57
- <div class="flex shrink-0 justify-end gap-x-3 border-t border-gray-200 px-4 py-4 dark:border-white/10">
58
- <%= yield :sidebar_footer %>
59
- </div>
60
- <% end %>
61
- </div>
62
- </div>
63
- <script>
64
- window.addEventListener('keydown', function(e) {
65
- if (e.key === 'Escape') {
66
- document.querySelectorAll('[data-toggle-target="toggleable"]').forEach(function(el) {
67
- el.classList.add('hidden');
68
- });
69
- }
70
- });
71
- </script>
72
- <% end %>
73
- </section>
74
- </div>
75
- </div>
76
- <%= render "panda/core/shared/footer" %>
1
+ <%= render Panda::Core::Admin::MainLayoutComponent.new(
2
+ user: current_user,
3
+ breadcrumbs: breadcrumbs
4
+ ) do %>
5
+ <%= yield %>
6
+ <% end %>
@@ -1,6 +1,6 @@
1
- <%= render "panda/core/shared/header", html_class: "h-full", body_class: "bg-gradient-admin" %>
1
+ <%= render Panda::Core::Shared::HeaderComponent.new(html_class: "", body_class: "bg-gradient-admin") %>
2
2
  <%= render "panda/core/admin/shared/flash" %>
3
3
  <div class="flex flex-col items-center justify-center min-h-screen px-4">
4
4
  <%= yield %>
5
5
  </div>
6
- <%= render "panda/core/shared/footer" %>
6
+ <%= render Panda::Core::Shared::FooterComponent.new %>