panda-core 0.4.1 → 0.6.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/tailwind/application.css +95 -0
  3. data/app/assets/tailwind/tailwind.config.js +8 -0
  4. data/app/components/panda/core/UI/button.rb +45 -24
  5. data/app/components/panda/core/admin/breadcrumb_component.rb +133 -0
  6. data/app/components/panda/core/admin/button_component.rb +1 -1
  7. data/app/components/panda/core/admin/container_component.rb +27 -4
  8. data/app/components/panda/core/admin/file_gallery_component.rb +157 -0
  9. data/app/components/panda/core/admin/flash_message_component.rb +54 -36
  10. data/app/components/panda/core/admin/heading_component.rb +8 -7
  11. data/app/components/panda/core/admin/page_header_component.rb +107 -0
  12. data/app/components/panda/core/admin/panel_component.rb +1 -1
  13. data/app/components/panda/core/admin/slideover_component.rb +62 -4
  14. data/app/components/panda/core/admin/table_component.rb +11 -11
  15. data/app/components/panda/core/admin/tag_component.rb +39 -2
  16. data/app/components/panda/core/admin/user_display_component.rb +4 -5
  17. data/app/controllers/panda/core/admin/my_profile_controller.rb +2 -2
  18. data/app/controllers/panda/core/admin/sessions_controller.rb +6 -2
  19. data/app/controllers/panda/core/admin/test_sessions_controller.rb +60 -0
  20. data/app/helpers/panda/core/asset_helper.rb +31 -5
  21. data/app/helpers/panda/core/sessions_helper.rb +26 -1
  22. data/app/javascript/panda/core/application.js +8 -1
  23. data/app/javascript/panda/core/controllers/alert_controller.js +38 -0
  24. data/app/javascript/panda/core/controllers/index.js +3 -3
  25. data/app/javascript/panda/core/controllers/toggle_controller.js +41 -0
  26. data/app/javascript/panda/core/tailwindplus-elements.js +31 -0
  27. data/app/models/panda/core/user.rb +49 -6
  28. data/app/services/panda/core/attach_avatar_service.rb +67 -0
  29. data/app/views/layouts/panda/core/admin_simple.html.erb +1 -0
  30. data/app/views/panda/core/admin/my_profile/edit.html.erb +26 -1
  31. data/app/views/panda/core/admin/sessions/new.html.erb +2 -3
  32. data/app/views/panda/core/admin/shared/_breadcrumbs.html.erb +3 -3
  33. data/app/views/panda/core/admin/shared/_sidebar.html.erb +17 -12
  34. data/config/importmap.rb +15 -7
  35. data/config/routes.rb +9 -0
  36. data/db/migrate/20250811120000_add_oauth_avatar_url_to_panda_core_users.rb +7 -0
  37. data/lib/panda/core/engine.rb +12 -3
  38. data/lib/panda/core/services/base_service.rb +19 -4
  39. data/lib/panda/core/version.rb +1 -1
  40. data/lib/panda/core.rb +1 -0
  41. data/lib/tasks/panda_core_users.rake +158 -0
  42. metadata +11 -1
@@ -7,15 +7,23 @@ module Panda
7
7
  prop :message, String
8
8
  prop :kind, Symbol
9
9
  prop :temporary, _Boolean, default: true
10
+ prop :subtitle, _Nilable(String), default: -> {}
10
11
 
11
12
  def view_template
12
- div(**container_attrs) do
13
- div(class: "overflow-hidden w-full max-w-sm bg-white rounded-lg ring-1 ring-black ring-opacity-5 shadow-lg") do
14
- div(class: "p-4") do
15
- div(class: "flex items-start") do
16
- render_icon
17
- render_content
18
- render_close_button
13
+ # Global notification container (fixed position)
14
+ div(
15
+ aria: {live: "assertive"},
16
+ class: "pointer-events-none fixed inset-0 flex items-end px-4 py-6 sm:items-start sm:p-6 z-50"
17
+ ) do
18
+ div(class: "flex w-full flex-col items-center space-y-4 sm:items-end") do
19
+ # Notification panel with Tailwind Plus styling
20
+ div(**notification_attrs) do
21
+ div(class: "p-4") do
22
+ div(class: "flex items-start") do
23
+ render_icon
24
+ render_content
25
+ render_close_button
26
+ end
19
27
  end
20
28
  end
21
29
  end
@@ -24,71 +32,81 @@ module Panda
24
32
 
25
33
  private
26
34
 
27
- def container_attrs
28
- attrs = {
29
- class: "fixed top-2 right-2 z-[9999] p-2 space-y-4 w-full max-w-sm sm:items-end",
35
+ def notification_attrs
36
+ {
37
+ class: "pointer-events-auto w-full max-w-sm translate-y-0 transform rounded-lg bg-white opacity-100 shadow-lg transition duration-300 ease-out sm:translate-x-0 dark:bg-gray-800 starting:translate-y-2 starting:opacity-0 starting:sm:translate-x-2 starting:sm:translate-y-0 #{border_color_css}",
30
38
  data: {
31
39
  controller: "alert",
32
- transition_enter: "ease-in-out duration-500",
33
- transition_enter_from: "translate-x-full opacity-0",
34
- transition_enter_to: "translate-x-0 opacity-100",
35
- transition_leave: "ease-in-out duration-500",
36
- transition_leave_from: "translate-x-0 opacity-100",
37
- transition_leave_to: "translate-x-full opacity-0"
38
- }
40
+ alert_dismiss_after_value: (@temporary ? "5000" : nil)
41
+ }.compact
39
42
  }
40
-
41
- attrs[:data][:alert_dismiss_after_value] = "3000" if @temporary
42
- attrs
43
43
  end
44
44
 
45
45
  def render_icon
46
- div(class: "flex-shrink-0") do
47
- i(class: "fa-regular text-xl #{icon_css} #{text_colour_css}")
46
+ div(class: "shrink-0") do
47
+ i(class: "fa-solid size-6 #{icon_css} #{icon_colour_css}")
48
48
  end
49
49
  end
50
50
 
51
51
  def render_content
52
- div(class: "flex-1 pt-0.5 ml-3 w-0") do
53
- p(class: "mb-1 text-sm font-medium flash-message-title #{text_colour_css}") { @kind.to_s.titleize }
54
- p(class: "mt-1 mb-0 text-sm text-gray-500 flash-message-text") { @message }
52
+ div(class: "ml-3 w-0 flex-1 pt-0.5") do
53
+ p(class: "text-sm font-medium text-gray-900 dark:text-white flash-message-title") { @message }
54
+ if @subtitle
55
+ p(class: "mt-1 text-sm text-gray-500 dark:text-gray-400 flash-message-subtitle") { @subtitle }
56
+ end
55
57
  end
56
58
  end
57
59
 
58
60
  def render_close_button
59
- div(class: "flex flex-shrink-0 ml-4") do
61
+ div(class: "ml-4 flex shrink-0") do
60
62
  button(
61
63
  type: "button",
62
- class: "inline-flex text-gray-400 bg-white rounded-md transition duration-150 ease-in-out hover:text-gray-500 focus:ring-2 focus:ring-offset-2 focus:outline-none focus:ring-sky-500",
64
+ class: "inline-flex rounded-md text-gray-400 hover:text-gray-500 focus:outline-2 focus:outline-offset-2 focus:outline-blue-600 dark:hover:text-white dark:focus:outline-blue-500",
63
65
  data: {action: "alert#close"}
64
66
  ) do
65
67
  span(class: "sr-only") { "Close" }
66
68
  svg(
67
- class: "w-5 h-5",
68
69
  viewBox: "0 0 20 20",
69
70
  fill: "currentColor",
70
- aria: {hidden: "true"}
71
+ data: {slot: "icon"},
72
+ aria: {hidden: "true"},
73
+ class: "size-5"
71
74
  ) do |s|
72
75
  s.path(
73
- d: "M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
76
+ d: "M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z"
74
77
  )
75
78
  end
76
79
  end
77
80
  end
78
81
  end
79
82
 
80
- def text_colour_css
83
+ def icon_colour_css
84
+ case @kind
85
+ when :success
86
+ "text-green-400 dark:text-green-500"
87
+ when :alert, :error
88
+ "text-red-400 dark:text-red-500"
89
+ when :warning
90
+ "text-yellow-400 dark:text-yellow-500"
91
+ when :info, :notice
92
+ "text-blue-400 dark:text-blue-500"
93
+ else
94
+ "text-gray-400 dark:text-gray-500"
95
+ end
96
+ end
97
+
98
+ def border_color_css
81
99
  case @kind
82
100
  when :success
83
- "text-green-600"
101
+ "ring-2 ring-green-400/20 dark:ring-green-500/30"
84
102
  when :alert, :error
85
- "text-red-600"
103
+ "ring-2 ring-red-400/20 dark:ring-red-500/30"
86
104
  when :warning
87
- "text-yellow-600"
105
+ "ring-2 ring-yellow-400/20 dark:ring-yellow-500/30"
88
106
  when :info, :notice
89
- "text-blue-600"
107
+ "ring-2 ring-blue-400/20 dark:ring-blue-500/30"
90
108
  else
91
- "text-gray-600"
109
+ "ring-1 ring-gray-400/10 dark:ring-gray-500/20"
92
110
  end
93
111
  end
94
112
 
@@ -21,7 +21,7 @@ module Panda
21
21
  when 3
22
22
  h3(class: heading_classes) { render_content }
23
23
  when :panel
24
- h3(class: panel_heading_classes) { render_content }
24
+ h3(class: panel_heading_classes) { @text }
25
25
  else
26
26
  h2(class: heading_classes) { render_content }
27
27
  end
@@ -35,12 +35,13 @@ module Panda
35
35
  private
36
36
 
37
37
  def render_content
38
- div(class: "grow") { @text }
38
+ div(class: "grow flex items-center gap-x-2") do
39
+ i(class: @icon) if @icon.present?
40
+ span { @text }
41
+ end
39
42
 
40
- if @buttons&.any?
41
- span(class: "actions flex gap-x-2 -mt-1") do
42
- @buttons.each { |btn| render(btn) }
43
- end
43
+ span(class: "actions flex gap-x-2 -mt-1 min-h-[2.5rem]") do
44
+ @buttons&.each { |btn| render(btn) }
44
45
  end
45
46
  end
46
47
 
@@ -61,7 +62,7 @@ module Panda
61
62
  end
62
63
 
63
64
  def panel_heading_classes
64
- "text-base font-medium p-4 text-white"
65
+ "text-base font-medium px-4 py-3 text-white"
65
66
  end
66
67
 
67
68
  def additional_styles_array
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ module Admin
6
+ # Page header component with title, optional breadcrumbs, and action buttons.
7
+ #
8
+ # Follows Tailwind UI Plus pattern for page headers with responsive layout
9
+ # and support for multiple action buttons.
10
+ #
11
+ # @example Basic header with title only
12
+ # render Panda::Core::Admin::PageHeaderComponent.new(
13
+ # title: "Back End Developer"
14
+ # )
15
+ #
16
+ # @example Header with breadcrumbs
17
+ # render Panda::Core::Admin::PageHeaderComponent.new(
18
+ # title: "Back End Developer",
19
+ # breadcrumbs: [
20
+ # { text: "Jobs", href: "/admin/jobs" },
21
+ # { text: "Engineering", href: "/admin/jobs/engineering" },
22
+ # { text: "Back End Developer", href: "/admin/jobs/engineering/1" }
23
+ # ]
24
+ # )
25
+ #
26
+ # @example Header with action buttons using block
27
+ # render Panda::Core::Admin::PageHeaderComponent.new(
28
+ # title: "Back End Developer",
29
+ # breadcrumbs: breadcrumb_items
30
+ # ) do |header|
31
+ # header.button(text: "Edit", variant: :secondary, href: edit_path)
32
+ # header.button(text: "Publish", variant: :primary, href: publish_path)
33
+ # end
34
+ #
35
+ class PageHeaderComponent < Panda::Core::Base
36
+ prop :title, String
37
+ prop :breadcrumbs, _Nilable(Array), default: -> {}
38
+ prop :show_back, _Boolean, default: true
39
+
40
+ def initialize(**props)
41
+ super
42
+ @buttons = []
43
+ end
44
+
45
+ def view_template(&block)
46
+ # Allow buttons to be defined via block
47
+ instance_eval(&block) if block_given?
48
+
49
+ div do
50
+ # Breadcrumbs section
51
+ if @breadcrumbs
52
+ render Panda::Core::Admin::BreadcrumbComponent.new(
53
+ items: @breadcrumbs,
54
+ show_back: @show_back
55
+ )
56
+ end
57
+
58
+ # Title and actions section
59
+ div(class: "mt-2 md:flex md:items-center md:justify-between") do
60
+ # Title
61
+ div(class: "min-w-0 flex-1") do
62
+ h2(class: "text-2xl/7 font-bold text-gray-900 sm:truncate sm:text-3xl sm:tracking-tight dark:text-white") do
63
+ @title
64
+ end
65
+ end
66
+
67
+ # Action buttons
68
+ if @buttons.any?
69
+ div(class: "mt-4 flex shrink-0 md:mt-0 md:ml-4") do
70
+ @buttons.each_with_index do |button_data, index|
71
+ render create_button(button_data, index)
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
78
+
79
+ # Define a button to be rendered in the header actions area
80
+ #
81
+ # @param text [String] Button text
82
+ # @param variant [Symbol] Button variant (:primary or :secondary)
83
+ # @param href [String] Link href
84
+ # @param props [Hash] Additional button properties
85
+ def button(text:, variant: :secondary, href: "#", **props)
86
+ @buttons << {text: text, variant: variant, href: href, **props}
87
+ end
88
+
89
+ private
90
+
91
+ def create_button(button_data, index)
92
+ Panda::Core::UI::Button.new(
93
+ text: button_data[:text],
94
+ variant: button_data[:variant],
95
+ href: button_data[:href],
96
+ class: button_margin_class(index),
97
+ **button_data.except(:text, :variant, :href)
98
+ )
99
+ end
100
+
101
+ def button_margin_class(index)
102
+ index.zero? ? "" : "ml-3"
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -16,7 +16,7 @@ module Panda
16
16
  end
17
17
  end
18
18
 
19
- div(class: "col-span-3 mt-5 rounded-lg shadow-md bg-gray-500 shadow-inherit/20") do
19
+ div(class: "col-span-3 mt-5 rounded-lg shadow-md bg-dark shadow-inherit/20") do
20
20
  @heading_content&.call
21
21
 
22
22
  div(class: "p-4 text-black bg-white rounded-b-lg") do
@@ -5,13 +5,71 @@ module Panda
5
5
  module Admin
6
6
  class SlideoverComponent < Panda::Core::Base
7
7
  prop :title, String, default: "Settings"
8
+ prop :open, _Nilable(_Boolean), default: -> { false }
8
9
 
9
10
  def view_template(&block)
10
- # Set content_for equivalents that can be accessed by the layout
11
- helpers.content_for(:sidebar_title) { title }
12
- helpers.content_for(:sidebar) do
13
- aside(class: "hidden overflow-y-auto w-96 h-full bg-white lg:block", &block)
11
+ # Capture block content
12
+ if block_given?
13
+ if defined?(view_context) && view_context
14
+ @content_html = view_context.capture(&block)
15
+ else
16
+ @content_block = block
17
+ end
14
18
  end
19
+
20
+ div(
21
+ **default_attrs,
22
+ data: {
23
+ toggle_target: "toggleable",
24
+ transition_enter: "transform transition ease-in-out duration-500",
25
+ transition_enter_from: "translate-x-full",
26
+ transition_enter_to: "translate-x-0",
27
+ transition_leave: "transform transition ease-in-out duration-500",
28
+ transition_leave_from: "translate-x-0",
29
+ transition_leave_to: "translate-x-full"
30
+ }
31
+ ) do
32
+ # Header with title and close button
33
+ div(class: "py-3 px-4 mb-4 bg-black") do
34
+ div(class: "flex justify-between items-center") do
35
+ h2(class: "text-base font-semibold leading-6 text-white", id: "slideover-title") do
36
+ i(class: "mr-2 fa-light fa-gear")
37
+ plain " #{@title}"
38
+ end
39
+ button(
40
+ type: "button",
41
+ data: {action: "click->toggle#toggle touch->toggle#toggle"},
42
+ class: "text-white hover:text-gray-300 transition"
43
+ ) do
44
+ i(class: "font-bold fa-regular fa-xmark right")
45
+ end
46
+ end
47
+ end
48
+
49
+ # Content area
50
+ div(class: "overflow-y-auto px-4 pb-6 space-y-6") do
51
+ if @content_html
52
+ raw(@content_html)
53
+ elsif @content_block
54
+ instance_eval(&@content_block)
55
+ end
56
+ end
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def default_attrs
63
+ {
64
+ id: "slideover",
65
+ class: slideover_classes
66
+ }
67
+ end
68
+
69
+ def slideover_classes
70
+ base = "flex absolute right-0 flex-col h-full bg-white divide-y divide-gray-200 shadow-xl basis-3/12 z-50"
71
+ visibility = @open ? "" : "hidden"
72
+ [base, visibility].compact.join(" ")
15
73
  end
16
74
  end
17
75
  end
@@ -6,6 +6,7 @@ module Panda
6
6
  class TableComponent < Panda::Core::Base
7
7
  prop :term, String
8
8
  prop :rows, _Nilable(Object), default: -> { [] }
9
+ prop :icon, String, default: ""
9
10
 
10
11
  attr_reader :columns
11
12
 
@@ -25,25 +26,22 @@ module Panda
25
26
  end
26
27
  end
27
28
 
28
- def column(label, &cell_block)
29
- @columns << Column.new(label, &cell_block)
29
+ def column(label, width: nil, &cell_block)
30
+ @columns << Column.new(label, width, &cell_block)
30
31
  end
31
32
 
32
33
  private
33
34
 
34
35
  def render_table_with_rows
35
- div(class: "table overflow-x-auto mb-12 w-full rounded-lg border border-dark") do
36
+ div(class: "table overflow-x-auto mb-12 w-full rounded-lg border border-dark", style: "table-layout: fixed;") do
36
37
  render_header
37
38
  render_rows
38
39
  end
39
40
  end
40
41
 
41
42
  def render_empty_table
42
- div(class: "table overflow-x-auto mb-12 w-full rounded-lg border border-dark") do
43
- render_header
44
- end
45
-
46
- div(class: "text-center mx-12 block border border-dashed py-12 rounded-lg") do
43
+ div(class: "text-center block border border-dashed py-12 rounded-lg") do
44
+ i(class: "#{@icon} text-4xl text-gray-400 mb-3") if @icon.present?
47
45
  h3(class: "py-1 text-xl font-semibold text-gray-900") { "No #{@term.pluralize}" }
48
46
  p(class: "py-1 text-base text-gray-500") { "Get started by creating a new #{@term}." }
49
47
  end
@@ -57,7 +55,8 @@ module Panda
57
55
  header_classes += " rounded-tl-md" if i.zero?
58
56
  header_classes += " rounded-tr-md" if i == @columns.size - 1
59
57
 
60
- div(class: header_classes) { column.label }
58
+ header_style = column.width ? "width: #{column.width};" : nil
59
+ div(class: header_classes, style: header_style) { column.label }
61
60
  end
62
61
  end
63
62
  end
@@ -106,10 +105,11 @@ module Panda
106
105
  end
107
106
 
108
107
  class Column
109
- attr_reader :label, :cell
108
+ attr_reader :label, :cell, :width
110
109
 
111
- def initialize(label, &block)
110
+ def initialize(label, width = nil, &block)
112
111
  @label = label
112
+ @width = width
113
113
  @cell = block
114
114
  end
115
115
  end
@@ -6,6 +6,7 @@ module Panda
6
6
  class TagComponent < Panda::Core::Base
7
7
  prop :status, Symbol, default: :active
8
8
  prop :text, _Nilable(String), default: -> {}
9
+ prop :page_type, _Nilable(Symbol), default: -> {}
9
10
 
10
11
  def view_template
11
12
  span(class: tag_classes) { computed_text }
@@ -14,12 +15,44 @@ module Panda
14
15
  private
15
16
 
16
17
  def computed_text
17
- @text || @status.to_s.humanize
18
+ if @page_type
19
+ @text || type_display_text
20
+ else
21
+ @text || @status.to_s.humanize
22
+ end
23
+ end
24
+
25
+ def type_display_text
26
+ case @page_type
27
+ when :standard
28
+ "Active"
29
+ when :hidden_type
30
+ "Hidden"
31
+ else
32
+ @page_type.to_s.humanize
33
+ end
18
34
  end
19
35
 
20
36
  def tag_classes
21
37
  base = "inline-flex items-center py-1 px-2 text-xs font-medium rounded-md ring-1 ring-inset "
22
- base + status_classes
38
+ base + (@page_type ? type_classes : status_classes)
39
+ end
40
+
41
+ def type_classes
42
+ case @page_type
43
+ when :system
44
+ "text-red-700 bg-red-100 ring-red-600/20 dark:bg-red-400/10 dark:text-red-400"
45
+ when :posts
46
+ "text-purple-700 bg-purple-100 ring-purple-600/20 dark:bg-purple-400/10 dark:text-purple-400"
47
+ when :code
48
+ "text-blue-700 bg-blue-100 ring-blue-600/20 dark:bg-blue-400/10 dark:text-blue-400"
49
+ when :standard
50
+ "text-green-700 bg-green-100 ring-green-600/20 dark:bg-green-400/10 dark:text-green-400"
51
+ when :hidden_type
52
+ "text-gray-700 bg-gray-100 ring-gray-600/20 dark:bg-gray-400/10 dark:text-gray-400"
53
+ else
54
+ "text-gray-700 bg-gray-100 ring-gray-600/20 dark:bg-gray-400/10 dark:text-gray-400"
55
+ end
23
56
  end
24
57
 
25
58
  def status_classes
@@ -30,6 +63,10 @@ module Panda
30
63
  "text-black ring-black/30 bg-yellow-400"
31
64
  when :inactive, :hidden
32
65
  "text-black ring-black/30 bg-black/5 bg-white"
66
+ when :auto
67
+ "text-blue-700 bg-blue-100 ring-blue-600/20 dark:bg-blue-400/10 dark:text-blue-400"
68
+ when :static
69
+ "text-gray-700 bg-gray-100 ring-gray-600/20 dark:bg-gray-400/10 dark:text-gray-400"
33
70
  else
34
71
  "text-black bg-white"
35
72
  end
@@ -30,15 +30,14 @@ module Panda
30
30
  end
31
31
 
32
32
  def render_avatar
33
- has_image = resolved_user.respond_to?(:image_url) &&
34
- resolved_user.image_url.present? &&
35
- !resolved_user.image_url.empty?
33
+ has_image = resolved_user.respond_to?(:avatar_url) &&
34
+ resolved_user.avatar_url.present?
36
35
 
37
36
  if has_image
38
37
  div do
39
38
  img(
40
- class: "inline-block w-10 h-10 rounded-full",
41
- src: resolved_user.image_url,
39
+ class: "inline-block w-10 h-10 rounded-full object-cover",
40
+ src: resolved_user.avatar_url,
42
41
  alt: ""
43
42
  )
44
43
  end
@@ -21,7 +21,7 @@ module Panda
21
21
  flash[:success] = "Your profile has been updated successfully."
22
22
  redirect_to edit_admin_my_profile_path
23
23
  else
24
- render :edit, locals: {user: current_user}, status: :unprocessable_entity
24
+ render :edit, locals: {user: current_user}, status: :unprocessable_content
25
25
  end
26
26
  end
27
27
 
@@ -36,7 +36,7 @@ module Panda
36
36
  # @return ActionController::StrongParameters
37
37
  def user_params
38
38
  # Base parameters that Core always allows
39
- base_params = [:name, :email, :current_theme]
39
+ base_params = [:name, :email, :current_theme, :avatar]
40
40
 
41
41
  # Allow additional params from configuration
42
42
  additional_params = Core.config.additional_user_params || []
@@ -30,7 +30,9 @@ module Panda
30
30
  if user.persisted?
31
31
  # Check if user is admin before allowing access
32
32
  unless user.admin?
33
- redirect_to admin_login_path, flash: {error: "You do not have permission to access the admin area"}
33
+ flash[:error] = "You do not have permission to access the admin area"
34
+ flash.keep(:error) if Rails.env.test?
35
+ redirect_to admin_login_path
34
36
  return
35
37
  end
36
38
 
@@ -58,7 +60,9 @@ module Panda
58
60
  strategy = params[:strategy] || "unknown"
59
61
 
60
62
  Rails.logger.error "OmniAuth failure: strategy=#{strategy}, message=#{message}"
61
- redirect_to admin_login_path, flash: {error: "Authentication failed: #{message}"}
63
+ flash[:error] = "Authentication failed: #{message}"
64
+ flash.keep(:error) if Rails.env.test?
65
+ redirect_to admin_login_path
62
66
  end
63
67
 
64
68
  def destroy
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ module Admin
6
+ # Test-only controller for setting up authentication in system tests
7
+ # This bypasses OAuth to avoid cross-process issues with Capybara
8
+ # Security: This route is only defined in test environments, never in production
9
+ #
10
+ # Usage in tests:
11
+ # post "/admin/test_sessions", params: { user_id: user.id }
12
+ # post "/admin/test_sessions", params: { user_id: user.id, return_to: "/some/path" }
13
+ class TestSessionsController < ActionController::Base
14
+ # Enable CSRF protection for consistency, then skip for test-only endpoint
15
+ protect_from_forgery with: :exception
16
+ skip_before_action :verify_authenticity_token, raise: false
17
+
18
+ def create
19
+ user = Panda::Core::User.find(params[:user_id])
20
+
21
+ # Check if user is admin (mimics real OAuth behavior)
22
+ unless user.admin?
23
+ # Non-admin users are redirected to login with error (mimics real OAuth flow)
24
+ flash[:alert] = "You do not have permission to access the admin area."
25
+ # Keep flash for one more request to survive redirect in tests
26
+ flash.keep(:alert) if Rails.env.test?
27
+ # Use string path since route helpers aren't available in ActionController::Base
28
+ redirect_to "#{Panda::Core.config.admin_path || "/admin"}/login", allow_other_host: false, status: :found
29
+ return
30
+ end
31
+
32
+ # Set session (mimics real OAuth callback)
33
+ session[:user_id] = user.id
34
+ Panda::Core::Current.user = user
35
+
36
+ # Support custom redirect path for test flexibility
37
+ redirect_path = params[:return_to] || determine_default_redirect_path
38
+ redirect_to redirect_path, allow_other_host: false, status: :found
39
+ rescue ActiveRecord::RecordNotFound
40
+ render html: "User not found: #{params[:user_id]}", status: :not_found
41
+ rescue => e
42
+ render html: "Error: #{e.class} - #{e.message}<br>#{e.backtrace.first(5).join("<br>")}", status: :internal_server_error
43
+ end
44
+
45
+ private
46
+
47
+ def determine_default_redirect_path
48
+ # Use configured dashboard path if available, otherwise default to admin root
49
+ if Panda::Core.config.dashboard_redirect_path
50
+ path = Panda::Core.config.dashboard_redirect_path
51
+ path.respond_to?(:call) ? path.call : path
52
+ else
53
+ # Use string path since route helpers aren't available in ActionController::Base
54
+ Panda::Core.config.admin_path || "/admin"
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -10,13 +10,39 @@ module Panda
10
10
 
11
11
  # Include only Core JavaScript
12
12
  def panda_core_javascript
13
- js_url = Panda::Core::AssetLoader.javascript_url
14
- return "" unless js_url
13
+ # Use asset_tags for development mode (importmap) compatibility
14
+ # In development, this will use importmap; in production/test, compiled bundles
15
+ if Panda::Core::AssetLoader.use_github_assets?
16
+ js_url = Panda::Core::AssetLoader.javascript_url
17
+ return "" unless js_url
15
18
 
16
- if js_url.start_with?("/panda-core-assets/")
17
- javascript_include_tag(js_url)
19
+ if js_url.start_with?("/panda-core-assets/")
20
+ javascript_include_tag(js_url)
21
+ else
22
+ javascript_include_tag(js_url, type: "module")
23
+ end
18
24
  else
19
- javascript_include_tag(js_url, type: "module")
25
+ # Development mode - Load JavaScript with import map
26
+ # Files are served by Rack::Static middleware from engine's app/javascript
27
+ importmap_html = <<~HTML
28
+ <script type="importmap">
29
+ {
30
+ "imports": {
31
+ "@hotwired/stimulus": "/panda/core/vendor/@hotwired--stimulus.js",
32
+ "@hotwired/turbo": "/panda/core/vendor/@hotwired--turbo.js",
33
+ "@rails/actioncable/src": "/panda/core/vendor/@rails--actioncable--src.js",
34
+ "tailwindcss-stimulus-components": "/panda/core/tailwindcss-stimulus-components.js",
35
+ "@fortawesome/fontawesome-free": "https://ga.jspm.io/npm:@fortawesome/fontawesome-free@7.1.0/js/all.js",
36
+ "panda/core/application": "/panda/core/application.js",
37
+ "panda/core/controllers/toggle_controller": "/panda/core/controllers/toggle_controller.js",
38
+ "panda/core/controllers/theme_form_controller": "/panda/core/controllers/theme_form_controller.js"
39
+ }
40
+ }
41
+ </script>
42
+ <script type="module" src="/panda/core/application.js"></script>
43
+ <script type="module" src="/panda/core/controllers/index.js"></script>
44
+ HTML
45
+ importmap_html.html_safe
20
46
  end
21
47
  end
22
48