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,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ module Admin
6
+ class UsersController < BaseController
7
+ before_action :set_initial_breadcrumb
8
+ before_action :set_user, only: %i[show edit update enable disable activity sessions revoke_session]
9
+
10
+ def index
11
+ @users = User.with_attached_avatar
12
+ @users = @users.search(params[:search]) if params[:search].present?
13
+
14
+ case params[:status]
15
+ when "enabled" then @users = @users.enabled
16
+ when "disabled" then @users = @users.disabled
17
+ when "invited" then @users = @users.invited
18
+ end
19
+
20
+ case params[:role]
21
+ when "admin" then @users = @users.admins
22
+ when "user" then @users = @users.where(User.admin_column => false)
23
+ end
24
+
25
+ @users = User.apply_metadata_filters(@users, params)
26
+
27
+ @users = case params[:sort]
28
+ when "name" then @users.order(name: sort_direction)
29
+ when "email" then @users.order(email: sort_direction)
30
+ when "last_login" then @users.order(last_login_at: sort_direction)
31
+ when "created" then @users.order(created_at: sort_direction)
32
+ else @users.order(name: :asc)
33
+ end
34
+
35
+ @page = [params.fetch(:page, 1).to_i, 1].max
36
+ @per_page = 25
37
+ @total_count = @users.count
38
+ @total_pages = (@total_count.to_f / @per_page).ceil
39
+ @users = @users.offset((@page - 1) * @per_page).limit(@per_page)
40
+ end
41
+
42
+ def show
43
+ add_breadcrumb @user.name, admin_user_path(@user)
44
+ @recent_activity = @user.user_activities.recent.limit(5)
45
+ @active_sessions = @user.user_sessions.active_sessions.recent
46
+ @active_sessions_count = @active_sessions.size
47
+ end
48
+
49
+ def edit
50
+ add_breadcrumb @user.name, admin_user_path(@user)
51
+ add_breadcrumb "Edit", edit_admin_user_path(@user)
52
+ end
53
+
54
+ def update
55
+ if @user.update(user_params)
56
+ if (hook = Panda::Core.config.admin_user_after_update)
57
+ hook.call(@user, params, current_user)
58
+ end
59
+ UserActivity.log!(user: current_user, action: "updated_user", resource: @user, request: request)
60
+ flash[:success] = "User has been updated successfully."
61
+ redirect_to admin_users_path
62
+ else
63
+ add_breadcrumb @user.name, admin_user_path(@user)
64
+ add_breadcrumb "Edit", edit_admin_user_path(@user)
65
+ render :edit, status: :unprocessable_content
66
+ end
67
+ end
68
+
69
+ def invite
70
+ result = InviteUserService.call(
71
+ email: params[:email],
72
+ name: params[:name],
73
+ invited_by: current_user,
74
+ admin: params[:admin] == "true"
75
+ )
76
+
77
+ if result.success?
78
+ if (hook = Panda::Core.config.after_user_invited)
79
+ hook.call(result.payload[:user], params, current_user)
80
+ end
81
+ flash[:success] = "Invitation sent to #{params[:email]}."
82
+ else
83
+ flash[:error] = result.errors.join(", ")
84
+ end
85
+ redirect_to admin_users_path
86
+ end
87
+
88
+ def enable
89
+ @user.enable!
90
+ UserActivity.log!(user: current_user, action: "enabled_user", resource: @user, request: request)
91
+ flash[:success] = "#{@user.name} has been enabled."
92
+ redirect_to admin_user_path(@user)
93
+ end
94
+
95
+ def disable
96
+ if @user == current_user
97
+ flash[:error] = "You cannot disable your own account."
98
+ redirect_to admin_user_path(@user)
99
+ return
100
+ end
101
+
102
+ @user.disable!
103
+ UserActivity.log!(user: current_user, action: "disabled_user", resource: @user, request: request)
104
+ flash[:success] = "#{@user.name} has been disabled."
105
+ redirect_to admin_user_path(@user)
106
+ end
107
+
108
+ def bulk_action
109
+ user_ids = Array(params[:user_ids]).map(&:to_s)
110
+ user_ids = user_ids.select { |id| id.match?(/\A[0-9a-f-]{36}\z/i) }
111
+
112
+ return redirect_to admin_users_path, alert: "No valid users selected." if user_ids.blank?
113
+
114
+ case params[:bulk_action]
115
+ when "enable"
116
+ enabled_count = User.where(id: user_ids).update_all(enabled: true)
117
+ flash[:success] = "#{enabled_count} user(s) enabled."
118
+ when "disable"
119
+ disabled_count = User.where(id: user_ids).where.not(id: current_user.id).update_all(enabled: false)
120
+ flash[:success] = "#{disabled_count} user(s) disabled (excluding yourself)."
121
+ else
122
+ flash[:error] = "Unknown action."
123
+ end
124
+ redirect_to admin_users_path
125
+ end
126
+
127
+ def activity
128
+ add_breadcrumb @user.name, admin_user_path(@user)
129
+ add_breadcrumb "Activity", activity_admin_user_path(@user)
130
+ @activities = @user.user_activities.recent
131
+ @page = [params.fetch(:page, 1).to_i, 1].max
132
+ @per_page = 25
133
+ @total_count = Rails.cache.fetch(["user_activities_count", @user.id], expires_in: 5.minutes) do
134
+ @activities.count
135
+ end
136
+ @total_pages = (@total_count.to_f / @per_page).ceil
137
+ @activities = @activities.offset((@page - 1) * @per_page).limit(@per_page)
138
+ end
139
+
140
+ def sessions
141
+ add_breadcrumb @user.name, admin_user_path(@user)
142
+ add_breadcrumb "Sessions", sessions_admin_user_path(@user)
143
+ @sessions = @user.user_sessions.recent
144
+ @page = [params.fetch(:page, 1).to_i, 1].max
145
+ @per_page = 25
146
+ @total_count = @sessions.count
147
+ @total_pages = (@total_count.to_f / @per_page).ceil
148
+ @sessions = @sessions.offset((@page - 1) * @per_page).limit(@per_page)
149
+ @active_sessions = @user.user_sessions.active_sessions.recent
150
+ @revoked_sessions = @user.user_sessions.revoked_sessions.recent
151
+ end
152
+
153
+ def revoke_session
154
+ session_record = @user.user_sessions.find(params[:session_id])
155
+ session_record.revoke!(admin: current_user)
156
+ UserActivity.log!(
157
+ user: current_user, action: "revoked_session", resource: @user,
158
+ request: request, metadata: {session_id: session_record.id}
159
+ )
160
+ flash[:success] = "Session revoked."
161
+ redirect_back fallback_location: admin_user_path(@user)
162
+ end
163
+
164
+ private
165
+
166
+ def set_initial_breadcrumb
167
+ add_breadcrumb "Users", admin_users_path
168
+ end
169
+
170
+ def set_user
171
+ @user = User.find(params[:id])
172
+ end
173
+
174
+ def user_params
175
+ params.require(:user).permit(:name, :email, :admin, :internal)
176
+ end
177
+
178
+ def sort_direction
179
+ %w[asc desc].include?(params[:direction]) ? params[:direction].to_sym : :asc
180
+ end
181
+ end
182
+ end
183
+ end
184
+ end
@@ -8,18 +8,30 @@ module Panda
8
8
  before_action :set_current_request_details
9
9
  before_action :initialize_breadcrumbs
10
10
 
11
+ # Prioritize markdown format for AI tools (Rails 8.1+)
12
+ # When Claude Code sends "Accept: text/markdown, text/html, */*", Rails would serve HTML by default.
13
+ # This reorders formats to prioritize markdown when it's the first accepted format.
14
+ before_action :prioritize_markdown_format
15
+
11
16
  helper_method :current_user, :user_signed_in?, :breadcrumbs
12
17
 
13
18
  add_flash_types :success, :error, :warning, :info
14
19
 
15
20
  private
16
21
 
22
+ # Prioritize markdown format when it's the first accepted format
23
+ # This ensures AI tools like Claude Code receive markdown responses instead of HTML
24
+ def prioritize_markdown_format
25
+ return unless request.accepts.first&.to_s == "text/markdown"
26
+ request.formats = [:md, :html]
27
+ end
28
+
17
29
  def set_current_request_details
18
30
  Current.request_id = request.uuid
19
31
  Current.user_agent = request.user_agent
20
32
  Current.ip_address = request.ip
21
33
  Current.root = "#{request.protocol}#{request.host_with_port}"
22
- Current.user = User.find_by(id: session[:user_id]) if session[:user_id]
34
+ Current.user = User.find_by(id: session[Panda::Core::ADMIN_SESSION_KEY]) if session[Panda::Core::ADMIN_SESSION_KEY]
23
35
  end
24
36
 
25
37
  def authenticate_user!
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ module ActiveStorageHelper
6
+ # Generates a URL for an ActiveStorage variant, compatible with both Rails 7.x and 8.1+.
7
+ #
8
+ # In Rails 8.1+, both url_for(variant) and image_tag(variant) break because
9
+ # VariantWithRecord no longer implements to_model. This helper constructs the
10
+ # representation proxy path directly from the blob and variation, avoiding
11
+ # all polymorphic routing that relies on to_model.
12
+ #
13
+ # @param variant [ActiveStorage::VariantWithRecord] The variant to generate a URL for
14
+ # @return [String, nil] The URL path, or nil if generation fails
15
+ def variant_representation_url(variant)
16
+ blob = variant.blob
17
+ "/rails/active_storage/representations/proxy/#{blob.signed_id}/#{variant.variation.key}/#{blob.filename}"
18
+ rescue => e
19
+ Rails.logger.warn "[Panda] Failed to generate variant URL (#{e.class}): #{e.message}"
20
+ nil
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ module FeatureFlagHelper
6
+ def feature_flag_enabled?(key)
7
+ Panda::Core::FeatureFlag.enabled?(key)
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ module FormHelper
6
+ # Wraps form_with to apply consistent admin form styling across all Panda engines.
7
+ # Sets the custom FormBuilder and applies default CSS classes for padding/layout.
8
+ def panda_form_with(**options, &)
9
+ options[:builder] = Panda::Core::FormBuilder
10
+ options[:class] = ["block visible", options[:class]].compact.join(" ")
11
+ form_with(**options, &)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,73 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Connects to data-controller="avatar-upload"
4
+ export default class extends Controller {
5
+ static targets = ["input", "preview", "placeholder", "fileInfo", "fileName", "fileSize"]
6
+
7
+ connect() {
8
+ this.originalPreviewSrc = this.hasPreviewTarget ? this.previewTarget.src : null
9
+ this.hadOriginalAvatar = this.hasPreviewTarget && !this.previewTarget.classList.contains("hidden")
10
+ }
11
+
12
+ handleFileSelect() {
13
+ const file = this.inputTarget.files[0]
14
+ if (!file || !file.type.startsWith("image/")) return
15
+
16
+ const reader = new FileReader()
17
+ reader.onload = (e) => {
18
+ // Show preview image, hide placeholder
19
+ if (this.hasPreviewTarget) {
20
+ this.previewTarget.src = e.target.result
21
+ this.previewTarget.classList.remove("hidden")
22
+ }
23
+ if (this.hasPlaceholderTarget) {
24
+ this.placeholderTarget.classList.add("hidden")
25
+ }
26
+
27
+ // Show file info
28
+ if (this.hasFileInfoTarget) {
29
+ this.fileInfoTarget.classList.remove("hidden")
30
+ }
31
+ if (this.hasFileNameTarget) {
32
+ this.fileNameTarget.textContent = file.name
33
+ }
34
+ if (this.hasFileSizeTarget) {
35
+ this.fileSizeTarget.textContent = this.formatFileSize(file.size)
36
+ }
37
+ }
38
+ reader.readAsDataURL(file)
39
+ }
40
+
41
+ remove() {
42
+ // Clear file input
43
+ this.inputTarget.value = ""
44
+
45
+ // Restore original state
46
+ if (this.hadOriginalAvatar && this.hasPreviewTarget) {
47
+ this.previewTarget.src = this.originalPreviewSrc
48
+ this.previewTarget.classList.remove("hidden")
49
+ if (this.hasPlaceholderTarget) {
50
+ this.placeholderTarget.classList.add("hidden")
51
+ }
52
+ } else {
53
+ // No original avatar — show placeholder
54
+ if (this.hasPreviewTarget) {
55
+ this.previewTarget.classList.add("hidden")
56
+ }
57
+ if (this.hasPlaceholderTarget) {
58
+ this.placeholderTarget.classList.remove("hidden")
59
+ }
60
+ }
61
+
62
+ // Hide file info
63
+ if (this.hasFileInfoTarget) {
64
+ this.fileInfoTarget.classList.add("hidden")
65
+ }
66
+ }
67
+
68
+ formatFileSize(bytes) {
69
+ if (bytes < 1024) return `${bytes} B`
70
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
71
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
72
+ }
73
+ }
@@ -0,0 +1,119 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Handles copy-to-clipboard and reveal/mask for secret values like API tokens.
4
+ //
5
+ // Usage:
6
+ // <div data-controller="clipboard"
7
+ // data-clipboard-secret-value="the-full-token"
8
+ // data-clipboard-masked-value="true">
9
+ // <span data-clipboard-target="display">...</span>
10
+ // <button data-action="click->clipboard#toggleReveal" data-clipboard-target="revealButton">
11
+ // <i data-clipboard-target="revealIcon" class="fa-solid fa-eye"></i>
12
+ // <span data-clipboard-target="revealText">Reveal</span>
13
+ // </button>
14
+ // <button data-action="click->clipboard#copy" data-clipboard-target="copyButton">
15
+ // <i data-clipboard-target="copyIcon" class="fa-solid fa-copy"></i>
16
+ // <span data-clipboard-target="copyText">Copy</span>
17
+ // </button>
18
+ // </div>
19
+ export default class extends Controller {
20
+ static values = {
21
+ secret: String,
22
+ masked: { type: Boolean, default: true }
23
+ }
24
+
25
+ static targets = [
26
+ "display",
27
+ "revealButton",
28
+ "revealIcon",
29
+ "revealText",
30
+ "copyButton",
31
+ "copyIcon",
32
+ "copyText"
33
+ ]
34
+
35
+ connect() {
36
+ this._feedbackTimeout = null
37
+ this.updateDisplay()
38
+ }
39
+
40
+ disconnect() {
41
+ if (this._feedbackTimeout) {
42
+ clearTimeout(this._feedbackTimeout)
43
+ this._feedbackTimeout = null
44
+ }
45
+ }
46
+
47
+ toggleReveal() {
48
+ this.maskedValue = !this.maskedValue
49
+ this.updateDisplay()
50
+ }
51
+
52
+ updateDisplay() {
53
+ if (this.hasDisplayTarget) {
54
+ this.displayTarget.textContent = this.maskedValue ? this.maskedSecret : this.secretValue
55
+ }
56
+
57
+ if (this.hasRevealIconTarget) {
58
+ this.revealIconTarget.className = this.maskedValue
59
+ ? "fa-solid fa-eye"
60
+ : "fa-solid fa-eye-slash"
61
+ }
62
+
63
+ if (this.hasRevealTextTarget) {
64
+ this.revealTextTarget.textContent = this.maskedValue ? "Reveal" : "Hide"
65
+ }
66
+ }
67
+
68
+ copy() {
69
+ navigator.clipboard.writeText(this.secretValue).then(() => {
70
+ this.showCopiedFeedback()
71
+ }).catch((err) => {
72
+ console.error("Failed to copy:", err)
73
+ })
74
+ }
75
+
76
+ showCopiedFeedback() {
77
+ if (!this.hasCopyButtonTarget) return
78
+
79
+ // Clear any pending feedback timeout from a previous copy
80
+ if (this._feedbackTimeout) {
81
+ clearTimeout(this._feedbackTimeout)
82
+ }
83
+
84
+ const button = this.copyButtonTarget
85
+ if (!this._originalClasses) {
86
+ this._originalClasses = button.className
87
+ }
88
+
89
+ // Update icon and text
90
+ if (this.hasCopyIconTarget) {
91
+ this.copyIconTarget.className = "fa-solid fa-check"
92
+ }
93
+ if (this.hasCopyTextTarget) {
94
+ this.copyTextTarget.textContent = "Copied!"
95
+ }
96
+
97
+ // Swap to success style
98
+ button.className = "shrink-0 btn btn-success transition"
99
+
100
+ // Restore after 2 seconds
101
+ this._feedbackTimeout = setTimeout(() => {
102
+ this._feedbackTimeout = null
103
+ button.className = this._originalClasses
104
+ if (this.hasCopyIconTarget) {
105
+ this.copyIconTarget.className = "fa-solid fa-copy"
106
+ }
107
+ if (this.hasCopyTextTarget) {
108
+ this.copyTextTarget.textContent = "Copy"
109
+ }
110
+ }, 2000)
111
+ }
112
+
113
+ get maskedSecret() {
114
+ const secret = this.secretValue
115
+ if (!secret || secret.length === 0) return ""
116
+ if (secret.length <= 4) return "\u2022".repeat(secret.length)
117
+ return "\u2022".repeat(12) + secret.slice(-4)
118
+ }
119
+ }
@@ -0,0 +1,45 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["body", "icon", "summary"]
5
+ static values = { expanded: { type: Boolean, default: false } }
6
+
7
+ connect() {
8
+ this.render()
9
+ }
10
+
11
+ toggle(event) {
12
+ // Don't toggle if clicking a button, link, input, select, or drag handle inside the header
13
+ if (event.target.closest("button, a, input, select, textarea, [data-sortable-handle]")) return
14
+
15
+ this.expandedValue = !this.expandedValue
16
+ this.render()
17
+ }
18
+
19
+ // No-op action to stop event propagation from nested buttons
20
+ noop() {}
21
+
22
+ render() {
23
+ if (this.hasBodyTarget) {
24
+ if (this.expandedValue) {
25
+ this.bodyTarget.style.maxHeight = this.bodyTarget.scrollHeight + "px"
26
+ this.bodyTarget.style.opacity = "1"
27
+ } else {
28
+ this.bodyTarget.style.maxHeight = "0px"
29
+ this.bodyTarget.style.opacity = "0"
30
+ }
31
+ }
32
+
33
+ if (this.hasIconTarget) {
34
+ this.iconTarget.style.transform = this.expandedValue ? "rotate(180deg)" : "rotate(0deg)"
35
+ }
36
+ }
37
+
38
+ // Update summary text from a form input event
39
+ updateSummary(event) {
40
+ if (this.hasSummaryTarget) {
41
+ const value = event.target.value.trim()
42
+ this.summaryTarget.textContent = value || this.summaryTarget.dataset.placeholder || "New item"
43
+ }
44
+ }
45
+ }