lean_cms 0.2.12

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 (130) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +235 -0
  3. data/LICENSE +21 -0
  4. data/README.md +107 -0
  5. data/app/assets/images/lean_cms/sloth-404.png +0 -0
  6. data/app/assets/images/lean_cms/sloth-500.png +0 -0
  7. data/app/assets/images/lean_cms/sloth-favicon-16.png +0 -0
  8. data/app/assets/images/lean_cms/sloth-favicon-32.png +0 -0
  9. data/app/assets/images/lean_cms/sloth-favicon-64.png +0 -0
  10. data/app/assets/images/lean_cms/sloth-logo.png +0 -0
  11. data/app/assets/lean_cms/actiontext.css +440 -0
  12. data/app/assets/lean_cms/cms_edit_controls.css +548 -0
  13. data/app/assets/tailwind/lean_cms/engine.css +14 -0
  14. data/app/components/lean_cms/base_component.rb +61 -0
  15. data/app/components/lean_cms/bullets_section_component.html.erb +23 -0
  16. data/app/components/lean_cms/bullets_section_component.rb +54 -0
  17. data/app/components/lean_cms/cards_section_component.html.erb +237 -0
  18. data/app/components/lean_cms/cards_section_component.rb +71 -0
  19. data/app/components/lean_cms/editable_content_component.html.erb +15 -0
  20. data/app/components/lean_cms/editable_content_component.rb +53 -0
  21. data/app/components/lean_cms/section_component.html.erb +18 -0
  22. data/app/components/lean_cms/section_component.rb +35 -0
  23. data/app/controllers/concerns/lean_cms/authentication.rb +60 -0
  24. data/app/controllers/concerns/lean_cms/authorization.rb +60 -0
  25. data/app/controllers/lean_cms/activity_controller.rb +16 -0
  26. data/app/controllers/lean_cms/application_controller.rb +48 -0
  27. data/app/controllers/lean_cms/dashboard_controller.rb +13 -0
  28. data/app/controllers/lean_cms/form_submissions_controller.rb +37 -0
  29. data/app/controllers/lean_cms/notification_settings_controller.rb +145 -0
  30. data/app/controllers/lean_cms/notifications_controller.rb +26 -0
  31. data/app/controllers/lean_cms/page_contents_controller.rb +403 -0
  32. data/app/controllers/lean_cms/password_setup_controller.rb +65 -0
  33. data/app/controllers/lean_cms/passwords_controller.rb +42 -0
  34. data/app/controllers/lean_cms/posts_controller.rb +78 -0
  35. data/app/controllers/lean_cms/sessions_controller.rb +50 -0
  36. data/app/controllers/lean_cms/settings_controller.rb +124 -0
  37. data/app/controllers/lean_cms/users_controller.rb +113 -0
  38. data/app/helpers/lean_cms/activity_helper.rb +190 -0
  39. data/app/helpers/lean_cms/application_helper.rb +43 -0
  40. data/app/helpers/lean_cms/content_helper.rb +34 -0
  41. data/app/helpers/lean_cms/page_content_helper.rb +359 -0
  42. data/app/javascript/controllers/cards_editor_controller.js +317 -0
  43. data/app/javascript/controllers/cms_sticky_overlay_controller.js +59 -0
  44. data/app/javascript/controllers/field_editor_form_controller.js +68 -0
  45. data/app/javascript/controllers/field_editor_modal_controller.js +79 -0
  46. data/app/javascript/controllers/inline_edit_controller.js +414 -0
  47. data/app/javascript/controllers/inline_edit_toggle_controller.js +81 -0
  48. data/app/javascript/controllers/notifications_controller.js +19 -0
  49. data/app/javascript/controllers/settings_inline_edit_sync_controller.js +38 -0
  50. data/app/javascript/controllers/settings_override_controller.js +45 -0
  51. data/app/mailers/lean_cms/application_mailer.rb +6 -0
  52. data/app/mailers/lean_cms/passwords_mailer.rb +8 -0
  53. data/app/mailers/lean_cms/users_mailer.rb +39 -0
  54. data/app/models/lean_cms/current.rb +6 -0
  55. data/app/models/lean_cms/form_submission.rb +45 -0
  56. data/app/models/lean_cms/magic_link.rb +76 -0
  57. data/app/models/lean_cms/meta_tag.rb +30 -0
  58. data/app/models/lean_cms/notification_setting.rb +69 -0
  59. data/app/models/lean_cms/page.rb +23 -0
  60. data/app/models/lean_cms/page_content.rb +245 -0
  61. data/app/models/lean_cms/post.rb +65 -0
  62. data/app/models/lean_cms/session.rb +7 -0
  63. data/app/models/lean_cms/setting.rb +156 -0
  64. data/app/policies/lean_cms/application_policy.rb +35 -0
  65. data/app/policies/lean_cms/page_content_policy.rb +31 -0
  66. data/app/policies/lean_cms/post_policy.rb +37 -0
  67. data/app/policies/lean_cms/setting_policy.rb +17 -0
  68. data/app/views/layouts/lean_cms/application.html.erb +114 -0
  69. data/app/views/layouts/lean_cms/auth.html.erb +200 -0
  70. data/app/views/lean_cms/activity/index.html.erb +79 -0
  71. data/app/views/lean_cms/dashboard/index.html.erb +180 -0
  72. data/app/views/lean_cms/form_submissions/index.html.erb +104 -0
  73. data/app/views/lean_cms/form_submissions/show.html.erb +157 -0
  74. data/app/views/lean_cms/notification_settings/edit.html.erb +192 -0
  75. data/app/views/lean_cms/notifications/index.html.erb +72 -0
  76. data/app/views/lean_cms/notifications/show.html.erb +39 -0
  77. data/app/views/lean_cms/page_contents/_field_editor.html.erb +174 -0
  78. data/app/views/lean_cms/page_contents/edit.html.erb +428 -0
  79. data/app/views/lean_cms/page_contents/index.html.erb +113 -0
  80. data/app/views/lean_cms/password_setup/show.html.erb +35 -0
  81. data/app/views/lean_cms/passwords/edit.html.erb +26 -0
  82. data/app/views/lean_cms/passwords/new.html.erb +21 -0
  83. data/app/views/lean_cms/passwords_mailer/reset.html.erb +6 -0
  84. data/app/views/lean_cms/passwords_mailer/reset.text.erb +4 -0
  85. data/app/views/lean_cms/posts/_form.html.erb +118 -0
  86. data/app/views/lean_cms/posts/edit.html.erb +31 -0
  87. data/app/views/lean_cms/posts/index.html.erb +100 -0
  88. data/app/views/lean_cms/posts/new.html.erb +16 -0
  89. data/app/views/lean_cms/sessions/new.html.erb +28 -0
  90. data/app/views/lean_cms/settings/edit.html.erb +384 -0
  91. data/app/views/lean_cms/shared/_admin_bar.html.erb +85 -0
  92. data/app/views/lean_cms/shared/_header.html.erb +86 -0
  93. data/app/views/lean_cms/shared/_notifications_bell.html.erb +84 -0
  94. data/app/views/lean_cms/shared/_sidebar.html.erb +102 -0
  95. data/app/views/lean_cms/users/_form.html.erb +105 -0
  96. data/app/views/lean_cms/users/edit.html.erb +8 -0
  97. data/app/views/lean_cms/users/index.html.erb +99 -0
  98. data/app/views/lean_cms/users/new.html.erb +8 -0
  99. data/app/views/lean_cms/users_mailer/admin_triggered_password_reset.html.erb +13 -0
  100. data/app/views/lean_cms/users_mailer/admin_triggered_password_reset.text.erb +11 -0
  101. data/app/views/lean_cms/users_mailer/invitation.html.erb +13 -0
  102. data/app/views/lean_cms/users_mailer/invitation.text.erb +11 -0
  103. data/app/views/lean_cms/users_mailer/reactivation.html.erb +13 -0
  104. data/app/views/lean_cms/users_mailer/reactivation.text.erb +11 -0
  105. data/config/importmap.rb +8 -0
  106. data/config/routes.rb +78 -0
  107. data/db/migrate/20251112034030_create_lean_cms_tables.rb +131 -0
  108. data/db/migrate/20260513000001_create_lean_cms_auth_tables.rb +31 -0
  109. data/db/migrate/20260514000001_create_paper_trail_versions.rb +16 -0
  110. data/db/migrate/20260514000002_create_action_text_tables.rb +18 -0
  111. data/db/migrate/20260514000003_create_active_storage_tables.rb +45 -0
  112. data/db/migrate/20260514000004_create_noticed_tables.rb +27 -0
  113. data/lib/generators/lean_cms/demo/demo_generator.rb +54 -0
  114. data/lib/generators/lean_cms/demo/templates/lean_cms_structure.yml +129 -0
  115. data/lib/generators/lean_cms/demo/templates/pages_controller.rb +30 -0
  116. data/lib/generators/lean_cms/demo/templates/views/pages/about.html.erb +40 -0
  117. data/lib/generators/lean_cms/demo/templates/views/pages/contact.html.erb +55 -0
  118. data/lib/generators/lean_cms/demo/templates/views/pages/home.html.erb +31 -0
  119. data/lib/generators/lean_cms/install/install_generator.rb +317 -0
  120. data/lib/generators/lean_cms/install/templates/add_lean_cms_columns_to_users.rb.tt +7 -0
  121. data/lib/generators/lean_cms/install/templates/lean_cms.rb +11 -0
  122. data/lib/generators/lean_cms/install/templates/lean_cms_structure.yml +29 -0
  123. data/lib/lean_cms/configuration.rb +32 -0
  124. data/lib/lean_cms/engine.rb +93 -0
  125. data/lib/lean_cms/loader.rb +217 -0
  126. data/lib/lean_cms/sync_helper.rb +182 -0
  127. data/lib/lean_cms/version.rb +3 -0
  128. data/lib/lean_cms.rb +26 -0
  129. data/lib/tasks/lean_cms.rake +390 -0
  130. metadata +313 -0
@@ -0,0 +1,145 @@
1
+ module LeanCms
2
+ class NotificationSettingsController < ApplicationController
3
+ include LeanCms::Authorization
4
+ skip_before_action :check_content_lock
5
+ before_action :require_settings_access
6
+
7
+ def edit
8
+ @settings = NotificationSetting.instance
9
+ end
10
+
11
+ def update
12
+ @settings = NotificationSetting.instance
13
+
14
+ # Update basic toggles
15
+ @settings.email_enabled = params[:email_enabled] == '1'
16
+ @settings.sms_enabled = params[:sms_enabled] == '1'
17
+ @settings.in_app_enabled = params[:in_app_enabled] == '1'
18
+ @settings.email_provider = params[:email_provider] || 'none'
19
+
20
+ # Update email credentials (only if provider is set and new value provided)
21
+ # Password fields will be empty if not changed, so only update if present
22
+ if params[:email_provider] == 'sendgrid'
23
+ @settings.sendgrid_api_key = params[:sendgrid_api_key] if params[:sendgrid_api_key].present?
24
+ @settings.mailgun_api_key = nil
25
+ @settings.mailgun_domain = nil
26
+ elsif params[:email_provider] == 'mailgun'
27
+ @settings.mailgun_api_key = params[:mailgun_api_key] if params[:mailgun_api_key].present?
28
+ @settings.mailgun_domain = params[:mailgun_domain] if params[:mailgun_domain].present?
29
+ @settings.sendgrid_api_key = nil
30
+ else
31
+ # Only clear if switching away from a provider
32
+ @settings.sendgrid_api_key = nil if @settings.email_provider == 'sendgrid'
33
+ @settings.mailgun_api_key = nil if @settings.email_provider == 'mailgun'
34
+ @settings.mailgun_domain = nil if @settings.email_provider == 'mailgun'
35
+ end
36
+
37
+ # Update SMS credentials
38
+ if params[:sms_enabled] == '1'
39
+ @settings.twilio_account_sid = params[:twilio_account_sid] if params[:twilio_account_sid].present?
40
+ @settings.twilio_auth_token = params[:twilio_auth_token] if params[:twilio_auth_token].present?
41
+ @settings.twilio_from_number = params[:twilio_from_number] if params[:twilio_from_number].present?
42
+ else
43
+ @settings.twilio_account_sid = nil
44
+ @settings.twilio_auth_token = nil
45
+ @settings.twilio_from_number = nil
46
+ end
47
+
48
+ # Update notification recipients
49
+ if params[:notification_emails].present?
50
+ emails = params[:notification_emails].split(',').map(&:strip).reject(&:blank?)
51
+ @settings.notification_email_list = emails
52
+ end
53
+
54
+ if params[:notification_phones].present?
55
+ phones = params[:notification_phones].split(',').map(&:strip).reject(&:blank?)
56
+ @settings.notification_phone_list = phones
57
+ end
58
+
59
+ if @settings.save
60
+ redirect_to edit_lean_cms_notification_settings_path, notice: 'Notification settings updated successfully.'
61
+ else
62
+ flash[:alert] = @settings.errors.full_messages.join(', ')
63
+ render :edit, status: :unprocessable_entity
64
+ end
65
+ end
66
+
67
+ def test_email
68
+ @settings = NotificationSetting.instance
69
+
70
+ unless @settings.email_enabled? && @settings.email_provider != 'none'
71
+ flash[:alert] = 'Email notifications must be enabled and configured before testing.'
72
+ redirect_to edit_lean_cms_notification_settings_path
73
+ return
74
+ end
75
+
76
+ # Create a test form submission
77
+ test_submission = LeanCms::FormSubmission.create!(
78
+ form_type: 'contact',
79
+ name: 'Test User',
80
+ email: 'test@example.com',
81
+ phone: '(555) 555-5555',
82
+ company_name: 'Test Company',
83
+ city: 'Test City',
84
+ state: 'WI',
85
+ zip: '54311',
86
+ message: 'This is a test notification from the CMS settings page.',
87
+ ip_address: request.remote_ip,
88
+ status: :new_submission
89
+ )
90
+
91
+ # Trigger notification
92
+ begin
93
+ ContactFormNotifier.with(submission: test_submission).deliver_later(User.where(can_access_settings: true).limit(1))
94
+ flash[:notice] = 'Test email notification sent successfully!'
95
+ rescue StandardError => e
96
+ Rails.logger.error "Test email error: #{e.message}"
97
+ flash[:alert] = "Failed to send test email: #{e.message}"
98
+ ensure
99
+ # Clean up test submission
100
+ test_submission.destroy
101
+ end
102
+
103
+ redirect_to edit_lean_cms_notification_settings_path
104
+ end
105
+
106
+ def test_sms
107
+ @settings = NotificationSetting.instance
108
+
109
+ unless @settings.sms_enabled?
110
+ flash[:alert] = 'SMS notifications must be enabled before testing.'
111
+ redirect_to edit_lean_cms_notification_settings_path
112
+ return
113
+ end
114
+
115
+ # Create a test form submission
116
+ test_submission = LeanCms::FormSubmission.create!(
117
+ form_type: 'contact',
118
+ name: 'Test User',
119
+ email: 'test@example.com',
120
+ phone: '(555) 555-5555',
121
+ company_name: 'Test Company',
122
+ city: 'Test City',
123
+ state: 'WI',
124
+ zip: '54311',
125
+ message: 'This is a test SMS notification from the CMS settings page.',
126
+ ip_address: request.remote_ip,
127
+ status: :new_submission
128
+ )
129
+
130
+ # Trigger notification
131
+ begin
132
+ ContactFormNotifier.with(submission: test_submission).deliver_later(User.where(can_access_settings: true).limit(1))
133
+ flash[:notice] = 'Test SMS notification sent successfully!'
134
+ rescue StandardError => e
135
+ Rails.logger.error "Test SMS error: #{e.message}"
136
+ flash[:alert] = "Failed to send test SMS: #{e.message}"
137
+ ensure
138
+ # Clean up test submission
139
+ test_submission.destroy
140
+ end
141
+
142
+ redirect_to edit_lean_cms_notification_settings_path
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,26 @@
1
+ module LeanCms
2
+ class NotificationsController < ApplicationController
3
+ include LeanCms::Authorization
4
+
5
+ def index
6
+ @notifications = current_user.notifications.order(created_at: :desc).page(params[:page]).per(20)
7
+ @unread_count = current_user.notifications.unread.count
8
+ end
9
+
10
+ def show
11
+ @notification = current_user.notifications.find(params[:id])
12
+ @notification.mark_as_read! unless @notification.read?
13
+ end
14
+
15
+ def mark_as_read
16
+ @notification = current_user.notifications.find(params[:id])
17
+ @notification.mark_as_read!
18
+ redirect_to lean_cms_notifications_path, notice: 'Notification marked as read.'
19
+ end
20
+
21
+ def mark_all_as_read
22
+ current_user.notifications.unread.update_all(read_at: Time.current)
23
+ redirect_to lean_cms_notifications_path, notice: 'All notifications marked as read.'
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,403 @@
1
+ module LeanCms
2
+ class PageContentsController < LeanCms::ApplicationController
3
+ before_action :require_page_editing
4
+ skip_before_action :check_content_lock
5
+ before_action :check_content_lock, only: [:update, :update_field, :undo_field]
6
+
7
+ def index
8
+ # Get all pages that have content associated with them
9
+ @pages = LeanCms::Page
10
+ .joins(:page_contents)
11
+ .select('lean_cms_pages.*, MIN(lean_cms_page_contents.page_order) as min_page_order')
12
+ .group('lean_cms_pages.id')
13
+ .order('min_page_order, lean_cms_pages.position, lean_cms_pages.title')
14
+ .distinct
15
+ .map { |page| { key: page.slug, display_title: page.title, page: page } }
16
+
17
+ @page_structure = {}
18
+ @pages.each do |page_data|
19
+ page_slug = page_data[:key]
20
+ page = page_data[:page]
21
+
22
+ sections = LeanCms::PageContent
23
+ .where(page_id: page.id)
24
+ .select(:section, :section_order, :display_title)
25
+ .distinct
26
+ .order(:section_order)
27
+ .pluck(:section, :display_title)
28
+ .uniq
29
+
30
+ @page_structure[page_slug] = sections.map do |section, display_title|
31
+ {
32
+ section: section,
33
+ display_title: display_title || section.humanize,
34
+ field_count: LeanCms::PageContent.where(page_id: page.id, section: section).count,
35
+ last_updated: LeanCms::PageContent.where(page_id: page.id, section: section).maximum(:updated_at),
36
+ has_cards: LeanCms::PageContent.where(page_id: page.id, section: section, key: 'cards').exists?,
37
+ has_bullets: LeanCms::PageContent.where(page_id: page.id, section: section, key: 'bullets').exists?
38
+ }
39
+ end
40
+ end
41
+ end
42
+
43
+ def edit
44
+ @page = params[:page]
45
+ @section = params[:section]
46
+
47
+ # Find the LeanCms::Page by slug
48
+ page_record = LeanCms::Page.find_by(slug: @page)
49
+
50
+ @fields = if page_record
51
+ # Use page_id for new data structure
52
+ LeanCms::PageContent.where(page_id: page_record.id, section: @section).ordered
53
+ else
54
+ # Fallback to string-based lookup for legacy data
55
+ LeanCms::PageContent.where("page = ? AND section = ?", @page, @section).ordered
56
+ end
57
+
58
+ redirect_to lean_cms_page_contents_path, alert: 'Section not found' if @fields.empty?
59
+ end
60
+
61
+ def update
62
+ @page = params[:page]
63
+ @section = params[:section]
64
+
65
+ success = true
66
+ errors = []
67
+
68
+ # Find the LeanCms::Page by slug
69
+ page_record = LeanCms::Page.find_by(slug: @page)
70
+
71
+ # Handle business hours special case (hours_json field)
72
+ if params[:hours_labels].present? || params[:hours_note].present?
73
+ hours_json_field = if page_record
74
+ LeanCms::PageContent.find_by(page_id: page_record.id, section: @section, key: 'hours_json')
75
+ else
76
+ LeanCms::PageContent.find_by("page = ? AND section = ? AND key = ?", @page, @section, 'hours_json')
77
+ end
78
+ if hours_json_field
79
+ hours_data = {
80
+ 'hours' => build_hours_array,
81
+ 'note' => params[:hours_note].to_s
82
+ }
83
+ hours_json_field.value = hours_data.to_json
84
+ hours_json_field.last_edited_by = current_user
85
+ unless hours_json_field.save
86
+ success = false
87
+ errors << "Hours: #{hours_json_field.errors.full_messages.join(', ')}"
88
+ end
89
+ end
90
+ end
91
+
92
+ # Handle bullets special case
93
+ if params[:bullet_items].present? && params[:bullets_field_id].present?
94
+ bullets_field = LeanCms::PageContent.find_by(id: params[:bullets_field_id])
95
+ if bullets_field
96
+ bullets_array = params[:bullet_items].map(&:to_s).reject(&:blank?)
97
+ bullets_field.value = bullets_array.to_json
98
+ bullets_field.last_edited_by = current_user
99
+ unless bullets_field.save
100
+ success = false
101
+ errors << "Bullets: #{bullets_field.errors.full_messages.join(', ')}"
102
+ end
103
+ end
104
+ end
105
+
106
+ # Update each field in the section
107
+ content_params = params[:page_contents] || {}
108
+
109
+ content_params.each do |field_id, field_data|
110
+ content = LeanCms::PageContent.find(field_id)
111
+ content.last_edited_by = current_user
112
+
113
+ # Handle different content types
114
+ if content.rich_text?
115
+ content.rich_content = field_data[:value]
116
+ elsif content.image? && field_data[:image_file].present?
117
+ content.image_file.attach(field_data[:image_file])
118
+ elsif content.boolean?
119
+ content.value = (field_data[:value] == '1' || field_data[:value] == 'true').to_s
120
+ elsif content.cards?
121
+ # For cards, the value is already JSON from the hidden input
122
+ cards_json = JSON.parse(field_data[:value]) rescue []
123
+
124
+ # Don't overwrite existing cards with an empty array — the cards editor
125
+ # hidden input may be blank when editing other fields in the same section form.
126
+ if cards_json.empty? && content.value.present?
127
+ existing = JSON.parse(content.value) rescue []
128
+ next if existing.any?
129
+ end
130
+
131
+ # Handle image uploads for cards
132
+ if field_data[:card_images].present?
133
+ # card_images is a hash with index keys: {"0" => file, "1" => file}
134
+ field_data[:card_images].each do |index_str, image_file|
135
+ index = index_str.to_i
136
+ next unless image_file.present? && cards_json[index].present?
137
+
138
+ # Attach the image to card_images collection
139
+ blob = ActiveStorage::Blob.create_and_upload!(
140
+ io: image_file,
141
+ filename: image_file.original_filename,
142
+ content_type: image_file.content_type
143
+ )
144
+
145
+ # Attach blob to the PageContent's card_images
146
+ content.card_images.attach(blob)
147
+
148
+ # Store blob ID in card data
149
+ cards_json[index]['image_id'] = blob.id.to_s
150
+ cards_json[index]['use_image'] = true
151
+ end
152
+ end
153
+
154
+ # Update cards JSON with image IDs
155
+ content.value = cards_json.to_json
156
+ else
157
+ content.value = field_data[:value]
158
+ end
159
+
160
+ unless content.save
161
+ success = false
162
+ errors << "#{content.label}: #{content.errors.full_messages.join(', ')}"
163
+ end
164
+ end
165
+
166
+ if success
167
+ # Clear cache for this page.
168
+ # SolidCache does not support delete_matched, so enumerate keys explicitly.
169
+ page_slug = @page.to_s
170
+ cache_scope = page_record ?
171
+ LeanCms::PageContent.where(page_id: page_record.id) :
172
+ LeanCms::PageContent.where("page = ?", page_slug)
173
+
174
+ cache_scope.pluck(:section, :key).each do |sec, k|
175
+ Rails.cache.delete("page_content/#{page_slug}/#{sec}/#{k}")
176
+ end
177
+ cache_scope.distinct.pluck(:section).each do |sec|
178
+ Rails.cache.delete("page_section/#{page_slug}/#{sec}")
179
+ end
180
+ Rails.cache.delete("page_structure/#{page_slug}")
181
+ Rails.cache.delete("page_cards/#{page_slug}/#{@section}")
182
+ Rails.cache.delete("page_bullets/#{page_slug}/#{@section}")
183
+
184
+ # Touch the LeanCms::Page to bust fragment cache
185
+ page_record&.touch
186
+
187
+ redirect_to lean_cms_page_contents_path, notice: 'Content updated successfully.'
188
+ else
189
+ redirect_to edit_lean_cms_page_content_path(page: @page, section: @section),
190
+ alert: "Errors: #{errors.join('; ')}"
191
+ end
192
+ end
193
+
194
+ def update_field
195
+ @field = LeanCms::PageContent.find(params[:id])
196
+ @field.last_edited_by = current_user
197
+
198
+ # Log what we're updating
199
+ Rails.logger.info "Updating field ##{@field.id}: #{@field.page}/#{@field.section}/#{@field.key}"
200
+ Rails.logger.info "Old value: #{@field.value.inspect}"
201
+ Rails.logger.info "New value: #{params[:value].inspect}"
202
+ Rails.logger.info "Content type: #{@field.content_type}"
203
+
204
+ case @field.content_type.to_sym
205
+ when :text, :url, :color, :dropdown
206
+ @field.value = params[:value]
207
+ when :rich_text
208
+ @field.rich_content = params[:value]
209
+ when :boolean
210
+ @field.value = (params[:value] == '1' || params[:value] == 'true').to_s
211
+ when :cards
212
+ # Cards are sent as JSON string
213
+ cards_json = JSON.parse(params[:value]) rescue []
214
+
215
+ # Handle image uploads for cards
216
+ if params[:card_images].present?
217
+ # card_images is a hash with index keys: {"0" => file, "1" => file}
218
+ params[:card_images].each do |index_str, image_file|
219
+ index = index_str.to_i
220
+ next unless image_file.present? && cards_json[index].present?
221
+
222
+ # Attach the image to card_images collection
223
+ blob = ActiveStorage::Blob.create_and_upload!(
224
+ io: image_file,
225
+ filename: image_file.original_filename,
226
+ content_type: image_file.content_type
227
+ )
228
+
229
+ # Attach blob to the PageContent's card_images
230
+ @field.card_images.attach(blob)
231
+
232
+ # Store blob ID in card data
233
+ cards_json[index]['image_id'] = blob.id.to_s
234
+ cards_json[index]['use_image'] = true
235
+ end
236
+ end
237
+
238
+ @field.value = cards_json.to_json
239
+ when :bullets
240
+ # Bullets can come as JSON string or array
241
+ if params[:value].is_a?(Array)
242
+ @field.value = params[:value].to_json
243
+ else
244
+ @field.value = params[:value]
245
+ end
246
+ when :image
247
+ if params[:image_file].present?
248
+ @field.image_file.attach(params[:image_file])
249
+ end
250
+ end
251
+
252
+ if @field.save
253
+ Rails.logger.info "Field saved successfully. New display value: #{@field.reload.display_value.inspect}"
254
+
255
+ # Clear cache
256
+ clear_page_cache(@field)
257
+
258
+ render json: {
259
+ success: true,
260
+ value: @field.display_value,
261
+ message: 'Content updated successfully'
262
+ }
263
+ else
264
+ Rails.logger.error "Failed to save field: #{@field.errors.full_messages.join(', ')}"
265
+ render json: {
266
+ success: false,
267
+ errors: @field.errors.full_messages
268
+ }, status: :unprocessable_entity
269
+ end
270
+ end
271
+
272
+ def edit_field
273
+ @field = LeanCms::PageContent.find(params[:id])
274
+
275
+ render partial: 'lean_cms/page_contents/field_editor',
276
+ locals: { field: @field },
277
+ layout: false
278
+ end
279
+
280
+ def preview_undo_field
281
+ @field = LeanCms::PageContent.find(params[:id])
282
+
283
+ previous_version = @field.versions.where(event: 'update').last
284
+
285
+ if previous_version && previous_version.object
286
+ previous_state = YAML.safe_load(
287
+ previous_version.object,
288
+ permitted_classes: [Symbol, Time, ActiveSupport::TimeWithZone, ActiveSupport::TimeZone, Date, DateTime],
289
+ aliases: true
290
+ )
291
+
292
+ old_value = previous_state['value']
293
+
294
+ if old_value
295
+ render json: {
296
+ success: true,
297
+ current_value: @field.display_value.to_s,
298
+ previous_value: old_value.to_s
299
+ }
300
+ else
301
+ render json: { success: false, error: 'Could not extract previous value' }, status: :unprocessable_entity
302
+ end
303
+ else
304
+ render json: { success: false, error: 'No previous version found' }, status: :not_found
305
+ end
306
+ end
307
+
308
+ def undo_field
309
+ @field = LeanCms::PageContent.find(params[:id])
310
+
311
+ # Get the most recent version from PaperTrail
312
+ previous_version = @field.versions.where(event: 'update').last
313
+
314
+ if previous_version && previous_version.object
315
+ # Parse the object to get the previous state
316
+ # The 'object' column contains the state BEFORE the change
317
+ previous_state = YAML.safe_load(
318
+ previous_version.object,
319
+ permitted_classes: [Symbol, Time, ActiveSupport::TimeWithZone, ActiveSupport::TimeZone, Date, DateTime],
320
+ aliases: true
321
+ )
322
+
323
+ Rails.logger.info "Previous state: #{previous_state.inspect}"
324
+
325
+ # Extract the old value
326
+ old_value = previous_state['value']
327
+
328
+ if old_value
329
+ # Set the old value back
330
+ case @field.content_type.to_sym
331
+ when :rich_text
332
+ @field.rich_content = old_value
333
+ else
334
+ @field.value = old_value
335
+ end
336
+
337
+ @field.last_edited_by = current_user
338
+
339
+ if @field.save
340
+ Rails.logger.info "Field reverted to previous version. New value: #{@field.reload.display_value.inspect}"
341
+
342
+ # Clear cache
343
+ clear_page_cache(@field)
344
+
345
+ render json: {
346
+ success: true,
347
+ value: @field.display_value,
348
+ message: 'Reverted to previous version'
349
+ }
350
+ else
351
+ render json: {
352
+ success: false,
353
+ error: 'Failed to save reverted version'
354
+ }, status: :unprocessable_entity
355
+ end
356
+ else
357
+ render json: {
358
+ success: false,
359
+ error: 'Could not extract previous value'
360
+ }, status: :unprocessable_entity
361
+ end
362
+ else
363
+ render json: {
364
+ success: false,
365
+ error: 'No previous version found'
366
+ }, status: :not_found
367
+ end
368
+ end
369
+
370
+ private
371
+
372
+ def clear_page_cache(field)
373
+ # SolidCache does not support delete_matched, so enumerate keys explicitly.
374
+ page_obj = field.page # LeanCms::Page via belongs_to, or nil for legacy records
375
+ page_slug = page_obj&.slug || field.read_attribute(:page).to_s
376
+
377
+ cache_scope = page_obj ?
378
+ LeanCms::PageContent.where(page_id: page_obj.id) :
379
+ LeanCms::PageContent.where("page = ?", page_slug)
380
+
381
+ cache_scope.pluck(:section, :key).each do |sec, k|
382
+ Rails.cache.delete("page_content/#{page_slug}/#{sec}/#{k}")
383
+ end
384
+ cache_scope.distinct.pluck(:section).each do |sec|
385
+ Rails.cache.delete("page_section/#{page_slug}/#{sec}")
386
+ end
387
+ Rails.cache.delete("page_structure/#{page_slug}")
388
+ Rails.cache.delete("page_cards/#{page_slug}/#{field.section}")
389
+ Rails.cache.delete("page_bullets/#{page_slug}/#{field.section}")
390
+
391
+ # Touch the LeanCms::Page to bust fragment cache
392
+ (page_obj || LeanCms::Page.find_by(slug: page_slug))&.touch
393
+ end
394
+
395
+ def build_hours_array
396
+ return [] unless params[:hours_labels]
397
+ params[:hours_labels]
398
+ .zip(params[:hours_values] || [])
399
+ .map { |label, value| { 'label' => label.to_s, 'value' => value.to_s } }
400
+ .reject { |h| h['label'].blank? && h['value'].blank? }
401
+ end
402
+ end
403
+ end
@@ -0,0 +1,65 @@
1
+ module LeanCms
2
+ class PasswordSetupController < ::ApplicationController
3
+ allow_unauthenticated_access
4
+ before_action :set_magic_link
5
+ before_action :ensure_link_valid
6
+
7
+ layout "lean_cms/auth"
8
+
9
+ def show
10
+ @user = @magic_link.user
11
+ end
12
+
13
+ def update
14
+ @user = @magic_link.user
15
+
16
+ if params[:password].blank?
17
+ flash.now[:alert] = "Password cannot be blank."
18
+ render :show, status: :unprocessable_entity
19
+ return
20
+ end
21
+
22
+ if params[:password] != params[:password_confirmation]
23
+ flash.now[:alert] = "Passwords do not match."
24
+ render :show, status: :unprocessable_entity
25
+ return
26
+ end
27
+
28
+ if params[:password].length < 8
29
+ flash.now[:alert] = "Password must be at least 8 characters."
30
+ render :show, status: :unprocessable_entity
31
+ return
32
+ end
33
+
34
+ @user.password = params[:password]
35
+ @user.password_confirmation = params[:password_confirmation]
36
+ @user.active = true
37
+ @user.must_change_password = false
38
+
39
+ if @user.save
40
+ @magic_link.mark_as_used!(request.remote_ip)
41
+ LeanCms::Session.where(user: @user).destroy_all
42
+ redirect_to lean_cms_new_session_path, notice: "Password set successfully. Please log in."
43
+ else
44
+ flash.now[:alert] = @user.errors.full_messages.join(", ")
45
+ render :show, status: :unprocessable_entity
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def set_magic_link
52
+ @magic_link = LeanCms::MagicLink.find_by(token: params[:token])
53
+ end
54
+
55
+ def ensure_link_valid
56
+ if @magic_link.nil?
57
+ redirect_to lean_cms_new_session_path, alert: "Invalid link."
58
+ elsif @magic_link.expired?
59
+ redirect_to lean_cms_new_session_path, alert: "This link has expired. Please request a new one."
60
+ elsif @magic_link.used?
61
+ redirect_to lean_cms_new_session_path, alert: "This link has already been used."
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,42 @@
1
+ module LeanCms
2
+ class PasswordsController < ::ApplicationController
3
+ allow_unauthenticated_access
4
+ before_action :set_user_by_token, only: %i[ edit update ]
5
+ rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to lean_cms_new_password_path, alert: "Try again later." }
6
+
7
+ layout "lean_cms/auth"
8
+
9
+ def new
10
+ end
11
+
12
+ def create
13
+ user_class = LeanCms.user_class.constantize
14
+
15
+ if user = user_class.find_by(email_address: params[:email_address])
16
+ LeanCms::PasswordsMailer.reset(user).deliver_later
17
+ end
18
+
19
+ redirect_to lean_cms_new_session_path, notice: "Password reset instructions sent (if user with that email address exists)."
20
+ end
21
+
22
+ def edit
23
+ end
24
+
25
+ def update
26
+ if @user.update(params.permit(:password, :password_confirmation))
27
+ LeanCms::Session.where(user: @user).destroy_all
28
+ redirect_to lean_cms_new_session_path, notice: "Password has been reset."
29
+ else
30
+ redirect_to lean_cms_edit_password_path(params[:token]), alert: "Passwords did not match."
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def set_user_by_token
37
+ @user = LeanCms.user_class.constantize.find_by_password_reset_token!(params[:token])
38
+ rescue ActiveSupport::MessageVerifier::InvalidSignature
39
+ redirect_to lean_cms_new_password_path, alert: "Password reset link is invalid or has expired."
40
+ end
41
+ end
42
+ end