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,90 @@
1
+ <%= render Panda::Core::Admin::ContainerComponent.new do |component| %>
2
+ <% component.with_heading_slot(text: "Activity: #{@user.name}", level: 1) do |heading| %>
3
+ <% heading.with_button(text: "Back to User", href: admin_user_path(@user), icon: "fa-solid fa-arrow-left") %>
4
+ <% end %>
5
+
6
+ <% if @activities.any? %>
7
+ <div class="flow-root">
8
+ <ul class="-mb-8">
9
+ <% @activities.each_with_index do |activity, index| %>
10
+ <li>
11
+ <div class="relative pb-8">
12
+ <% unless index == @activities.length - 1 %>
13
+ <span class="absolute left-4 top-4 -ml-px h-full w-0.5 bg-gray-200 dark:bg-gray-700" aria-hidden="true"></span>
14
+ <% end %>
15
+ <div class="relative flex space-x-3">
16
+ <div>
17
+ <% icon_config = case activity.action
18
+ when "login"
19
+ {icon: "fa-solid fa-right-to-bracket", bg: "bg-green-500"}
20
+ when "logout"
21
+ {icon: "fa-solid fa-right-from-bracket", bg: "bg-gray-400"}
22
+ when "updated_user"
23
+ {icon: "fa-solid fa-user-pen", bg: "bg-blue-500"}
24
+ when "enabled_user"
25
+ {icon: "fa-solid fa-user-check", bg: "bg-green-500"}
26
+ when "disabled_user"
27
+ {icon: "fa-solid fa-user-slash", bg: "bg-red-500"}
28
+ when "invited_user"
29
+ {icon: "fa-solid fa-envelope", bg: "bg-purple-500"}
30
+ when "revoked_session"
31
+ {icon: "fa-solid fa-ban", bg: "bg-red-500"}
32
+ else
33
+ {icon: "fa-solid fa-circle-info", bg: "bg-gray-400"}
34
+ end %>
35
+ <span class="flex h-8 w-8 items-center justify-center rounded-full <%= icon_config[:bg] %> ring-8 ring-white dark:ring-gray-900">
36
+ <i class="<%= icon_config[:icon] %> text-white text-xs"></i>
37
+ </span>
38
+ </div>
39
+ <div class="flex min-w-0 flex-1 justify-between space-x-4 pt-1.5">
40
+ <div>
41
+ <p class="text-sm text-gray-900 dark:text-white">
42
+ <strong><%= activity.action.humanize %></strong>
43
+ <% if activity.resource_type.present? %>
44
+ <span class="text-gray-500 dark:text-gray-400">
45
+ &mdash; <%= activity.resource_type.demodulize %>
46
+ <% if activity.resource_id.present? %>
47
+ (<%= activity.resource_id.to_s.first(8) %>...)
48
+ <% end %>
49
+ </span>
50
+ <% end %>
51
+ </p>
52
+ <% if activity.ip_address.present? %>
53
+ <p class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
54
+ IP: <%= activity.ip_address %>
55
+ </p>
56
+ <% end %>
57
+ <% if activity.metadata.present? && activity.metadata.any? %>
58
+ <div class="mt-1">
59
+ <% activity.metadata.each do |key, value| %>
60
+ <span class="inline-flex items-center rounded bg-gray-100 dark:bg-gray-700 px-1.5 py-0.5 text-xs text-gray-600 dark:text-gray-400 mr-1">
61
+ <%= key %>: <%= value %>
62
+ </span>
63
+ <% end %>
64
+ </div>
65
+ <% end %>
66
+ </div>
67
+ <div class="whitespace-nowrap text-right text-xs text-gray-500 dark:text-gray-400">
68
+ <time datetime="<%= activity.created_at.iso8601 %>"><%= time_ago_in_words(activity.created_at) %> ago</time>
69
+ <div class="text-gray-400 dark:text-gray-500"><%= activity.created_at.strftime("%b %d, %Y %H:%M") %></div>
70
+ </div>
71
+ </div>
72
+ </div>
73
+ </div>
74
+ </li>
75
+ <% end %>
76
+ </ul>
77
+ </div>
78
+
79
+ <%= render Panda::Core::Admin::PaginationComponent.new(
80
+ page: @page, total_pages: @total_pages, total_count: @total_count,
81
+ per_page: @per_page, item_name: "activities"
82
+ ) %>
83
+ <% else %>
84
+ <%= render Panda::Core::Admin::EmptyStateComponent.new(
85
+ icon: "fa-solid fa-clock-rotate-left",
86
+ title: "No activity recorded",
87
+ description: "Activity will appear here once this user starts using the system."
88
+ ) %>
89
+ <% end %>
90
+ <% end %>
@@ -0,0 +1,48 @@
1
+ <%= render Panda::Core::Admin::ContainerComponent.new do |component| %>
2
+ <% component.with_heading_slot(text: "Edit User: #{@user.name}", level: 1) %>
3
+
4
+ <%= panda_form_with model: @user,
5
+ url: admin_user_path(@user),
6
+ method: :patch,
7
+ local: true do |f| %>
8
+ <%= render Panda::Core::Admin::FormErrorComponent.new(model: @user) %>
9
+
10
+ <%= render Panda::Core::Admin::PanelComponent.new do |panel| %>
11
+ <% panel.with_heading_slot { "User Settings" } %>
12
+ <div class="space-y-4">
13
+ <!-- User Info Display (Read-only) -->
14
+ <div class="flex items-center gap-4 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
15
+ <% url = @user.avatar_url(size: :medium) %>
16
+ <% if url.present? %>
17
+ <img src="<%= url %>" alt="<%= @user.name %>" class="h-16 w-16 rounded-full object-cover">
18
+ <% else %>
19
+ <div class="h-16 w-16 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center">
20
+ <i class="fa-solid fa-user text-xl text-gray-400 dark:text-gray-300"></i>
21
+ </div>
22
+ <% end %>
23
+ <div>
24
+ <p class="text-sm text-gray-500 dark:text-gray-400">Editing profile for</p>
25
+ <p class="font-medium text-gray-900 dark:text-white"><%= @user.email %></p>
26
+ </div>
27
+ </div>
28
+
29
+ <%= f.text_field :name %>
30
+ <%= f.email_field :email %>
31
+ <%= f.check_box :admin, {
32
+ label: "Administrator",
33
+ meta: @user == current_user ? "You cannot change your own admin status." : "Grant this user full administrative access.",
34
+ disabled: (@user == current_user)
35
+ } %>
36
+ <%= f.check_box :internal, {
37
+ label: "Internal Account",
38
+ meta: "Exclude from reports and analytics."
39
+ } %>
40
+ <% if (hook = Panda::Core.config.admin_user_edit_content) %>
41
+ <%= raw hook.call(@user, f, self) %>
42
+ <% end %>
43
+ </div>
44
+ <% end %>
45
+
46
+ <%= render Panda::Core::Admin::FormFooterComponent.new(submit_text: "Update User") %>
47
+ <% end %>
48
+ <% end %>
@@ -0,0 +1,161 @@
1
+ <%= render Panda::Core::Admin::ContainerComponent.new do |component| %>
2
+ <% component.with_heading_slot(text: "Users", level: 1) do |heading| %>
3
+ <% heading.with_button(
4
+ text: "Invite User",
5
+ icon: "fa-solid fa-plus",
6
+ data: { action: "click->toggle#toggle" }
7
+ ) %>
8
+ <% end %>
9
+
10
+ <%= render Panda::Core::Admin::SearchFilterBarComponent.new(
11
+ url: admin_users_path,
12
+ search_name: :search,
13
+ search_value: params[:search],
14
+ search_placeholder: "Search by name or email...",
15
+ show_clear: params[:search].present? || params[:status].present? || params[:role].present? || Panda::Core::User.metadata_filter_active?(params)
16
+ ) do |bar| %>
17
+ <% bar.with_filter do %>
18
+ <%= select_tag :status,
19
+ options_for_select([["All Statuses", ""], ["Enabled", "enabled"], ["Disabled", "disabled"], ["Invited", "invited"]], params[:status]),
20
+ class: bar.select_classes %>
21
+ <% end %>
22
+ <% bar.with_filter do %>
23
+ <%= select_tag :role,
24
+ options_for_select([["All Roles", ""], ["Admin", "admin"], ["User", "user"]], params[:role]),
25
+ class: bar.select_classes %>
26
+ <% end %>
27
+ <% Panda::Core::User.filterable_metadata_fields.each do |key, config| %>
28
+ <% bar.with_filter do %>
29
+ <%= select_tag key,
30
+ options_for_select(config[:filter_options], params[key.to_sym]),
31
+ class: bar.select_classes %>
32
+ <% end %>
33
+ <% end %>
34
+ <% end %>
35
+
36
+ <!-- Bulk Actions Bar -->
37
+ <div id="bulk-actions-bar" class="hidden mb-4 rounded-lg bg-primary-50 dark:bg-primary-900/20 p-3 flex items-center justify-between">
38
+ <span class="text-sm text-primary-700 dark:text-primary-300">
39
+ <span id="selected-count">0</span> user(s) selected
40
+ </span>
41
+ <div class="flex gap-2">
42
+ <button type="button" onclick="submitBulkAction('enable')" class="rounded-md bg-green-600 px-3 py-1.5 text-xs font-semibold text-white shadow-sm hover:bg-green-500">Enable</button>
43
+ <button type="button" onclick="submitBulkAction('disable')" class="rounded-md bg-red-600 px-3 py-1.5 text-xs font-semibold text-white shadow-sm hover:bg-red-500">Disable</button>
44
+ </div>
45
+ </div>
46
+
47
+ <%= form_with url: bulk_action_admin_users_path, method: :post, id: "bulk-action-form", local: true do %>
48
+ <%= hidden_field_tag :bulk_action, "", id: "bulk-action-field" %>
49
+
50
+ <% if @users.any? %>
51
+ <%= render Panda::Core::Admin::TableComponent.new(term: "user", rows: @users, icon: "users") do |table| %>
52
+ <% table.column("", width: "5%") do |user| %>
53
+ <input type="checkbox" name="user_ids[]" value="<%= user.id %>"
54
+ class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 bulk-checkbox"
55
+ onchange="updateBulkActions()">
56
+ <% end %>
57
+
58
+ <% table.column("User", width: "30%") do |user| %>
59
+ <%= link_to admin_user_path(user), class: "block-link flex items-center gap-3 group" do %>
60
+ <% url = user.avatar_url(size: :thumb) %>
61
+ <% if url.present? %>
62
+ <img src="<%= url %>" alt="<%= user.name %>" class="h-10 w-10 shrink-0 rounded-full object-cover">
63
+ <% else %>
64
+ <div class="h-10 w-10 shrink-0 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center">
65
+ <i class="fa-solid fa-user text-gray-400 dark:text-gray-300"></i>
66
+ </div>
67
+ <% end %>
68
+ <div>
69
+ <div class="font-medium text-gray-900 dark:text-white group-hover:text-primary-600 dark:group-hover:text-primary-400"><%= user.name %></div>
70
+ <div class="text-sm text-gray-500 dark:text-gray-400"><%= user.email %></div>
71
+ </div>
72
+ <% end %>
73
+ <% end %>
74
+
75
+ <% table.column("Role", width: "15%") do |user| %>
76
+ <% if user.admin? %>
77
+ <%= render Panda::Core::Admin::TagComponent.new(text: "Admin", status: :active) %>
78
+ <% else %>
79
+ <%= render Panda::Core::Admin::TagComponent.new(text: "User", status: :inactive) %>
80
+ <% end %>
81
+ <% if user.internal? %>
82
+ <%= render Panda::Core::Admin::TagComponent.new(text: "Internal", status: :auto) %>
83
+ <% end %>
84
+ <% if (hook = Panda::Core.config.admin_user_index_columns) %>
85
+ <%= raw hook.call(user, self) %>
86
+ <% end %>
87
+ <% end %>
88
+
89
+ <% table.column("Status", width: "15%") do |user| %>
90
+ <% if !user.enabled? %>
91
+ <%= render Panda::Core::Admin::TagComponent.new(text: "Disabled", status: :inactive) %>
92
+ <% elsif user.invitation_token.present? %>
93
+ <%= render Panda::Core::Admin::TagComponent.new(text: "Invited", status: :draft) %>
94
+ <% else %>
95
+ <%= render Panda::Core::Admin::TagComponent.new(text: "Enabled", status: :active) %>
96
+ <% end %>
97
+ <% end %>
98
+
99
+ <% table.column("Last Login", width: "20%") do |user| %>
100
+ <% if user.last_login_at.present? %>
101
+ <span class="text-sm text-gray-500 dark:text-gray-400" title="<%= l(user.last_login_at, format: :long) %>">
102
+ <%= time_ago_in_words(user.last_login_at) %> ago
103
+ </span>
104
+ <% else %>
105
+ <span class="text-sm text-gray-400 dark:text-gray-500">Never</span>
106
+ <% end %>
107
+ <% end %>
108
+ <% end %>
109
+ <% else %>
110
+ <%= render Panda::Core::Admin::EmptyStateComponent.new(
111
+ icon: "fa-solid fa-users",
112
+ title: "No users found",
113
+ description: params[:search].present? ? "Try adjusting your search or filter criteria." : "No users have been created yet."
114
+ ) %>
115
+ <% end %>
116
+ <% end %>
117
+
118
+ <%= render Panda::Core::Admin::PaginationComponent.new(
119
+ page: @page, total_pages: @total_pages, total_count: @total_count,
120
+ per_page: @per_page, item_name: "users"
121
+ ) %>
122
+
123
+ <!-- Invite User Slideover -->
124
+ <% component.with_slideover_slot(title: "Invite User") do %>
125
+ <%= panda_form_with url: invite_admin_users_path, method: :post, local: true do |f| %>
126
+ <%= render Panda::Core::Admin::PanelComponent.new do |panel| %>
127
+ <% panel.with_heading_slot { "User Details" } %>
128
+ <div class="space-y-4">
129
+ <%= f.text_field :name, required: true, placeholder: "Full name" %>
130
+ <%= f.email_field :email, required: true, placeholder: "email@example.com" %>
131
+ <%= f.check_box :admin, { label: "Administrator", meta: "Grant full administrative access." } %>
132
+ <% if (hook = Panda::Core.config.invite_form_content) %>
133
+ <%= raw hook.call(f, self) %>
134
+ <% end %>
135
+ </div>
136
+ <% end %>
137
+ <%= render Panda::Core::Admin::FormFooterComponent.new(submit_text: "Send Invitation") %>
138
+ <% end %>
139
+ <% end %>
140
+ <% end %>
141
+
142
+ <script>
143
+ document.addEventListener('DOMContentLoaded', function() {
144
+ window.updateBulkActions = function() {
145
+ var checked = document.querySelectorAll('.bulk-checkbox:checked');
146
+ var bar = document.getElementById('bulk-actions-bar');
147
+ var count = document.getElementById('selected-count');
148
+ if (checked.length > 0) {
149
+ bar.classList.remove('hidden');
150
+ count.textContent = checked.length;
151
+ } else {
152
+ bar.classList.add('hidden');
153
+ }
154
+ };
155
+
156
+ window.submitBulkAction = function(action) {
157
+ document.getElementById('bulk-action-field').value = action;
158
+ document.getElementById('bulk-action-form').submit();
159
+ };
160
+ });
161
+ </script>
@@ -0,0 +1,103 @@
1
+ <%= render Panda::Core::Admin::ContainerComponent.new do |component| %>
2
+ <% component.with_heading_slot(text: "Sessions: #{@user.name}", level: 1) do |heading| %>
3
+ <% heading.with_button(text: "Back to User", href: admin_user_path(@user), icon: "fa-solid fa-arrow-left") %>
4
+ <% end %>
5
+
6
+ <% active_sessions = @active_sessions %>
7
+ <% revoked_sessions = @revoked_sessions %>
8
+
9
+ <!-- Active Sessions -->
10
+ <div class="mb-8">
11
+ <h2 class="text-lg font-medium text-gray-900 dark:text-white mb-4">
12
+ Active Sessions
13
+ <% if active_sessions.any? %>
14
+ <span class="ml-2 inline-flex items-center rounded-full bg-green-50 px-2 py-0.5 text-xs font-medium text-green-700 dark:bg-green-500/10 dark:text-green-400"><%= active_sessions.count %></span>
15
+ <% end %>
16
+ </h2>
17
+
18
+ <% if active_sessions.any? %>
19
+ <div class="space-y-3">
20
+ <% active_sessions.each do |user_session| %>
21
+ <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4 flex items-center justify-between">
22
+ <div class="flex items-center gap-4">
23
+ <div class="flex-shrink-0">
24
+ <span class="flex h-10 w-10 items-center justify-center rounded-full bg-green-100 dark:bg-green-500/10">
25
+ <i class="fa-solid fa-desktop text-green-600 dark:text-green-400"></i>
26
+ </span>
27
+ </div>
28
+ <div>
29
+ <div class="text-sm font-medium text-gray-900 dark:text-white">
30
+ <% if user_session.user_agent.present? %>
31
+ <%= truncate(user_session.user_agent, length: 80) %>
32
+ <% else %>
33
+ Unknown browser
34
+ <% end %>
35
+ </div>
36
+ <div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
37
+ <% if user_session.ip_address.present? %>
38
+ IP: <%= user_session.ip_address %> &middot;
39
+ <% end %>
40
+ Last active: <%= user_session.last_active_at ? "#{time_ago_in_words(user_session.last_active_at)} ago" : "Unknown" %>
41
+ </div>
42
+ </div>
43
+ </div>
44
+ <% if @user != current_user %>
45
+ <%= button_to "Revoke",
46
+ revoke_session_admin_user_path(@user, session_id: user_session.id),
47
+ method: :delete,
48
+ class: "rounded-md bg-red-600 px-3 py-1.5 text-xs font-semibold text-white shadow-sm hover:bg-red-500 cursor-pointer",
49
+ form: { data: { turbo_confirm: "Revoke this session? The user will be logged out." } } %>
50
+ <% end %>
51
+ </div>
52
+ <% end %>
53
+ </div>
54
+ <% else %>
55
+ <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6 text-center">
56
+ <p class="text-sm text-gray-500 dark:text-gray-400">No active sessions.</p>
57
+ </div>
58
+ <% end %>
59
+ </div>
60
+
61
+ <!-- Revoked Sessions -->
62
+ <% if revoked_sessions.any? %>
63
+ <div>
64
+ <h2 class="text-lg font-medium text-gray-900 dark:text-white mb-4">
65
+ Revoked Sessions
66
+ <span class="ml-2 inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-400"><%= revoked_sessions.count %></span>
67
+ </h2>
68
+
69
+ <div class="space-y-3">
70
+ <% revoked_sessions.each do |user_session| %>
71
+ <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4 opacity-60">
72
+ <div class="flex items-center gap-4">
73
+ <div class="flex-shrink-0">
74
+ <span class="flex h-10 w-10 items-center justify-center rounded-full bg-gray-100 dark:bg-gray-700">
75
+ <i class="fa-solid fa-desktop text-gray-400 dark:text-gray-500"></i>
76
+ </span>
77
+ </div>
78
+ <div class="flex-1">
79
+ <div class="text-sm font-medium text-gray-900 dark:text-white">
80
+ <% if user_session.user_agent.present? %>
81
+ <%= truncate(user_session.user_agent, length: 80) %>
82
+ <% else %>
83
+ Unknown browser
84
+ <% end %>
85
+ </div>
86
+ <div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
87
+ <% if user_session.ip_address.present? %>
88
+ IP: <%= user_session.ip_address %> &middot;
89
+ <% end %>
90
+ Revoked: <%= user_session.revoked_at ? "#{time_ago_in_words(user_session.revoked_at)} ago" : "Unknown" %>
91
+ <% if user_session.revoked_by.present? %>
92
+ by <%= user_session.revoked_by.name %>
93
+ <% end %>
94
+ </div>
95
+ </div>
96
+ <span class="inline-flex items-center rounded-full bg-red-50 px-2 py-0.5 text-xs font-medium text-red-700 dark:bg-red-500/10 dark:text-red-400">Revoked</span>
97
+ </div>
98
+ </div>
99
+ <% end %>
100
+ </div>
101
+ </div>
102
+ <% end %>
103
+ <% end %>
@@ -0,0 +1,224 @@
1
+ <%= render Panda::Core::Admin::ContainerComponent.new do |component| %>
2
+ <% component.with_heading_slot(text: @user.name, level: 1) do |heading| %>
3
+ <% heading.with_button(text: "Edit", href: edit_admin_user_path(@user), icon: "fa-solid fa-pencil") %>
4
+ <% end %>
5
+
6
+ <div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
7
+ <!-- User Info Card -->
8
+ <div class="lg:col-span-1 space-y-6">
9
+ <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6">
10
+ <div class="flex flex-col items-center">
11
+ <% url = @user.avatar_url(size: :large) %>
12
+ <% if url.present? %>
13
+ <img src="<%= url %>" alt="<%= @user.name %>" class="h-32 w-32 rounded-full object-cover mb-4">
14
+ <% else %>
15
+ <div class="h-32 w-32 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center mb-4">
16
+ <i class="fa-solid fa-user text-4xl text-gray-400 dark:text-gray-300"></i>
17
+ </div>
18
+ <% end %>
19
+ <h2 class="text-xl font-semibold text-gray-900 dark:text-white"><%= @user.name %></h2>
20
+ <p class="text-gray-500 dark:text-gray-400"><%= @user.email %></p>
21
+ <div class="mt-3 flex items-center gap-2">
22
+ <% if @user.admin? %>
23
+ <%= render Panda::Core::Admin::TagComponent.new(text: "Administrator", status: :active) %>
24
+ <% else %>
25
+ <%= render Panda::Core::Admin::TagComponent.new(text: "Standard User", status: :inactive) %>
26
+ <% end %>
27
+ <% if @user.internal? %>
28
+ <%= render Panda::Core::Admin::TagComponent.new(text: "Internal", status: :auto) %>
29
+ <% end %>
30
+ <% if !@user.enabled? %>
31
+ <%= render Panda::Core::Admin::TagComponent.new(text: "Disabled", status: :inactive) %>
32
+ <% elsif @user.invitation_token.present? %>
33
+ <%= render Panda::Core::Admin::TagComponent.new(text: "Invited", status: :draft) %>
34
+ <% end %>
35
+ <% if (hook = Panda::Core.config.admin_user_show_content) %>
36
+ <%= raw hook.call(@user, self) %>
37
+ <% end %>
38
+ </div>
39
+ </div>
40
+
41
+ <!-- Enable/Disable Actions -->
42
+ <div class="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
43
+ <% if @user == current_user %>
44
+ <p class="text-sm text-gray-500 dark:text-gray-400 text-center">This is your account.</p>
45
+ <% elsif @user.enabled? %>
46
+ <%= button_to "Disable User", disable_admin_user_path(@user), method: :patch,
47
+ class: "w-full rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 cursor-pointer",
48
+ form: { data: { turbo_confirm: "Are you sure you want to disable #{@user.name}?" } } %>
49
+ <% else %>
50
+ <%= button_to "Enable User", enable_admin_user_path(@user), method: :patch,
51
+ class: "w-full rounded-md bg-green-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-green-500 cursor-pointer" %>
52
+ <% end %>
53
+ </div>
54
+ </div>
55
+
56
+ <!-- Quick Stats -->
57
+ <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6">
58
+ <h3 class="text-sm font-medium text-gray-500 dark:text-gray-400 tracking-wider mb-4">Quick Stats</h3>
59
+ <dl class="space-y-3">
60
+ <div class="flex justify-between">
61
+ <dt class="text-sm text-gray-600 dark:text-gray-400">Login Count</dt>
62
+ <dd class="text-sm font-medium text-gray-900 dark:text-white"><%= @user.login_count || 0 %></dd>
63
+ </div>
64
+ <div class="flex justify-between">
65
+ <dt class="text-sm text-gray-600 dark:text-gray-400">Active Sessions</dt>
66
+ <dd class="text-sm font-medium text-gray-900 dark:text-white"><%= @active_sessions_count %></dd>
67
+ </div>
68
+ <div class="flex justify-between">
69
+ <dt class="text-sm text-gray-600 dark:text-gray-400">Last Login</dt>
70
+ <dd class="text-sm font-medium text-gray-900 dark:text-white">
71
+ <% if @user.last_login_at.present? %>
72
+ <%= time_ago_in_words(@user.last_login_at) %> ago
73
+ <% else %>
74
+ Never
75
+ <% end %>
76
+ </dd>
77
+ </div>
78
+ <% if @user.invited_by.present? %>
79
+ <div class="flex justify-between">
80
+ <dt class="text-sm text-gray-600 dark:text-gray-400">Invited By</dt>
81
+ <dd class="text-sm font-medium text-gray-900 dark:text-white">
82
+ <%= link_to @user.invited_by.name, admin_user_path(@user.invited_by), class: "text-primary-600 hover:text-primary-700 dark:text-primary-400" %>
83
+ </dd>
84
+ </div>
85
+ <% end %>
86
+ </dl>
87
+ </div>
88
+ </div>
89
+
90
+ <!-- User Details -->
91
+ <div class="lg:col-span-2 space-y-6">
92
+ <!-- Account Information -->
93
+ <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm">
94
+ <div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
95
+ <h3 class="text-sm font-medium text-gray-500 dark:text-gray-400 tracking-wider">Account Information</h3>
96
+ </div>
97
+ <dl class="divide-y divide-gray-200 dark:divide-gray-700">
98
+ <div class="px-6 py-4 sm:grid sm:grid-cols-3 sm:gap-4">
99
+ <dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Email</dt>
100
+ <dd class="mt-1 text-sm text-gray-900 dark:text-white sm:col-span-2 sm:mt-0"><%= @user.email %></dd>
101
+ </div>
102
+ <div class="px-6 py-4 sm:grid sm:grid-cols-3 sm:gap-4">
103
+ <dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Member since</dt>
104
+ <dd class="mt-1 text-sm text-gray-900 dark:text-white sm:col-span-2 sm:mt-0"><%= l(@user.created_at, format: :long) %></dd>
105
+ </div>
106
+ <div class="px-6 py-4 sm:grid sm:grid-cols-3 sm:gap-4">
107
+ <dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Last updated</dt>
108
+ <dd class="mt-1 text-sm text-gray-900 dark:text-white sm:col-span-2 sm:mt-0"><%= l(@user.updated_at, format: :long) %></dd>
109
+ </div>
110
+ <% if @user.last_login_ip.present? %>
111
+ <div class="px-6 py-4 sm:grid sm:grid-cols-3 sm:gap-4">
112
+ <dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Last login IP</dt>
113
+ <dd class="mt-1 text-sm text-gray-900 dark:text-white sm:col-span-2 sm:mt-0"><%= @user.last_login_ip %></dd>
114
+ </div>
115
+ <% end %>
116
+ <% if @user.current_theme.present? %>
117
+ <div class="px-6 py-4 sm:grid sm:grid-cols-3 sm:gap-4">
118
+ <dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Theme preference</dt>
119
+ <dd class="mt-1 text-sm text-gray-900 dark:text-white sm:col-span-2 sm:mt-0"><%= @user.current_theme.titleize %></dd>
120
+ </div>
121
+ <% end %>
122
+ </dl>
123
+ </div>
124
+
125
+ <!-- Recent Activity -->
126
+ <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm">
127
+ <div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
128
+ <h3 class="text-sm font-medium text-gray-500 dark:text-gray-400 tracking-wider">Recent Activity</h3>
129
+ <%= link_to "View All", activity_admin_user_path(@user), class: "text-sm text-primary-600 hover:text-primary-700 dark:text-primary-400" %>
130
+ </div>
131
+ <% if @recent_activity.any? %>
132
+ <ul class="divide-y divide-gray-200 dark:divide-gray-700">
133
+ <% @recent_activity.each do |activity| %>
134
+ <li class="px-6 py-3 flex items-center justify-between">
135
+ <div class="flex items-center gap-3">
136
+ <div class="flex-shrink-0">
137
+ <% icon = case activity.action
138
+ when "login" then "fa-solid fa-right-to-bracket text-green-500"
139
+ when "logout" then "fa-solid fa-right-from-bracket text-gray-400"
140
+ when "updated_user" then "fa-solid fa-user-pen text-blue-500"
141
+ when "enabled_user" then "fa-solid fa-user-check text-green-500"
142
+ when "disabled_user" then "fa-solid fa-user-slash text-red-500"
143
+ when "invited_user" then "fa-solid fa-envelope text-purple-500"
144
+ when "revoked_session" then "fa-solid fa-ban text-red-500"
145
+ else "fa-solid fa-circle-info text-gray-400"
146
+ end %>
147
+ <i class="<%= icon %>"></i>
148
+ </div>
149
+ <div>
150
+ <span class="text-sm text-gray-900 dark:text-white"><%= activity.action.humanize %></span>
151
+ <% if activity.resource_type.present? %>
152
+ <span class="text-sm text-gray-500 dark:text-gray-400">
153
+ &mdash; <%= activity.resource_type.demodulize %>
154
+ </span>
155
+ <% end %>
156
+ </div>
157
+ </div>
158
+ <span class="text-xs text-gray-400 dark:text-gray-500"><%= time_ago_in_words(activity.created_at) %> ago</span>
159
+ </li>
160
+ <% end %>
161
+ </ul>
162
+ <% else %>
163
+ <div class="px-6 py-8 text-center">
164
+ <p class="text-sm text-gray-500 dark:text-gray-400">No activity recorded yet.</p>
165
+ </div>
166
+ <% end %>
167
+ </div>
168
+
169
+ <!-- Active Sessions -->
170
+ <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm">
171
+ <div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
172
+ <h3 class="text-sm font-medium text-gray-500 dark:text-gray-400 tracking-wider">
173
+ Active Sessions
174
+ <% if @active_sessions_count > 0 %>
175
+ <span class="ml-2 inline-flex items-center rounded-full bg-green-50 px-2 py-0.5 text-xs font-medium text-green-700 dark:bg-green-500/10 dark:text-green-400"><%= @active_sessions_count %></span>
176
+ <% end %>
177
+ </h3>
178
+ <%= link_to "View All Sessions", sessions_admin_user_path(@user), class: "text-sm text-primary-600 hover:text-primary-700 dark:text-primary-400" %>
179
+ </div>
180
+ <% if @active_sessions.any? %>
181
+ <div class="divide-y divide-gray-200 dark:divide-gray-700">
182
+ <% @active_sessions.each do |user_session| %>
183
+ <div class="px-6 py-4 flex items-center justify-between">
184
+ <div class="flex items-center gap-4">
185
+ <div class="flex-shrink-0">
186
+ <span class="flex h-10 w-10 items-center justify-center rounded-full bg-green-100 dark:bg-green-500/10">
187
+ <i class="fa-solid fa-desktop text-green-600 dark:text-green-400"></i>
188
+ </span>
189
+ </div>
190
+ <div>
191
+ <div class="text-sm font-medium text-gray-900 dark:text-white">
192
+ <% if user_session.user_agent.present? %>
193
+ <%= truncate(user_session.user_agent, length: 80) %>
194
+ <% else %>
195
+ Unknown browser
196
+ <% end %>
197
+ </div>
198
+ <div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
199
+ <% if user_session.ip_address.present? %>
200
+ IP: <%= user_session.ip_address %> &middot;
201
+ <% end %>
202
+ Last active: <%= user_session.last_active_at ? "#{time_ago_in_words(user_session.last_active_at)} ago" : "Unknown" %>
203
+ </div>
204
+ </div>
205
+ </div>
206
+ <% if @user != current_user %>
207
+ <%= button_to "Revoke",
208
+ revoke_session_admin_user_path(@user, session_id: user_session.id),
209
+ method: :delete,
210
+ class: "rounded-md bg-red-600 px-3 py-1.5 text-xs font-semibold text-white shadow-sm hover:bg-red-500 cursor-pointer",
211
+ form: { data: { turbo_confirm: "Revoke this session? The user will be logged out." } } %>
212
+ <% end %>
213
+ </div>
214
+ <% end %>
215
+ </div>
216
+ <% else %>
217
+ <div class="px-6 py-8 text-center">
218
+ <p class="text-sm text-gray-500 dark:text-gray-400">No active sessions.</p>
219
+ </div>
220
+ <% end %>
221
+ </div>
222
+ </div>
223
+ </div>
224
+ <% end %>
@@ -1,2 +1 @@
1
- </body>
2
- </html>
1
+ <%= render Panda::Core::Shared::FooterComponent.new %>