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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +235 -0
- data/LICENSE +21 -0
- data/README.md +107 -0
- data/app/assets/images/lean_cms/sloth-404.png +0 -0
- data/app/assets/images/lean_cms/sloth-500.png +0 -0
- data/app/assets/images/lean_cms/sloth-favicon-16.png +0 -0
- data/app/assets/images/lean_cms/sloth-favicon-32.png +0 -0
- data/app/assets/images/lean_cms/sloth-favicon-64.png +0 -0
- data/app/assets/images/lean_cms/sloth-logo.png +0 -0
- data/app/assets/lean_cms/actiontext.css +440 -0
- data/app/assets/lean_cms/cms_edit_controls.css +548 -0
- data/app/assets/tailwind/lean_cms/engine.css +14 -0
- data/app/components/lean_cms/base_component.rb +61 -0
- data/app/components/lean_cms/bullets_section_component.html.erb +23 -0
- data/app/components/lean_cms/bullets_section_component.rb +54 -0
- data/app/components/lean_cms/cards_section_component.html.erb +237 -0
- data/app/components/lean_cms/cards_section_component.rb +71 -0
- data/app/components/lean_cms/editable_content_component.html.erb +15 -0
- data/app/components/lean_cms/editable_content_component.rb +53 -0
- data/app/components/lean_cms/section_component.html.erb +18 -0
- data/app/components/lean_cms/section_component.rb +35 -0
- data/app/controllers/concerns/lean_cms/authentication.rb +60 -0
- data/app/controllers/concerns/lean_cms/authorization.rb +60 -0
- data/app/controllers/lean_cms/activity_controller.rb +16 -0
- data/app/controllers/lean_cms/application_controller.rb +48 -0
- data/app/controllers/lean_cms/dashboard_controller.rb +13 -0
- data/app/controllers/lean_cms/form_submissions_controller.rb +37 -0
- data/app/controllers/lean_cms/notification_settings_controller.rb +145 -0
- data/app/controllers/lean_cms/notifications_controller.rb +26 -0
- data/app/controllers/lean_cms/page_contents_controller.rb +403 -0
- data/app/controllers/lean_cms/password_setup_controller.rb +65 -0
- data/app/controllers/lean_cms/passwords_controller.rb +42 -0
- data/app/controllers/lean_cms/posts_controller.rb +78 -0
- data/app/controllers/lean_cms/sessions_controller.rb +50 -0
- data/app/controllers/lean_cms/settings_controller.rb +124 -0
- data/app/controllers/lean_cms/users_controller.rb +113 -0
- data/app/helpers/lean_cms/activity_helper.rb +190 -0
- data/app/helpers/lean_cms/application_helper.rb +43 -0
- data/app/helpers/lean_cms/content_helper.rb +34 -0
- data/app/helpers/lean_cms/page_content_helper.rb +359 -0
- data/app/javascript/controllers/cards_editor_controller.js +317 -0
- data/app/javascript/controllers/cms_sticky_overlay_controller.js +59 -0
- data/app/javascript/controllers/field_editor_form_controller.js +68 -0
- data/app/javascript/controllers/field_editor_modal_controller.js +79 -0
- data/app/javascript/controllers/inline_edit_controller.js +414 -0
- data/app/javascript/controllers/inline_edit_toggle_controller.js +81 -0
- data/app/javascript/controllers/notifications_controller.js +19 -0
- data/app/javascript/controllers/settings_inline_edit_sync_controller.js +38 -0
- data/app/javascript/controllers/settings_override_controller.js +45 -0
- data/app/mailers/lean_cms/application_mailer.rb +6 -0
- data/app/mailers/lean_cms/passwords_mailer.rb +8 -0
- data/app/mailers/lean_cms/users_mailer.rb +39 -0
- data/app/models/lean_cms/current.rb +6 -0
- data/app/models/lean_cms/form_submission.rb +45 -0
- data/app/models/lean_cms/magic_link.rb +76 -0
- data/app/models/lean_cms/meta_tag.rb +30 -0
- data/app/models/lean_cms/notification_setting.rb +69 -0
- data/app/models/lean_cms/page.rb +23 -0
- data/app/models/lean_cms/page_content.rb +245 -0
- data/app/models/lean_cms/post.rb +65 -0
- data/app/models/lean_cms/session.rb +7 -0
- data/app/models/lean_cms/setting.rb +156 -0
- data/app/policies/lean_cms/application_policy.rb +35 -0
- data/app/policies/lean_cms/page_content_policy.rb +31 -0
- data/app/policies/lean_cms/post_policy.rb +37 -0
- data/app/policies/lean_cms/setting_policy.rb +17 -0
- data/app/views/layouts/lean_cms/application.html.erb +114 -0
- data/app/views/layouts/lean_cms/auth.html.erb +200 -0
- data/app/views/lean_cms/activity/index.html.erb +79 -0
- data/app/views/lean_cms/dashboard/index.html.erb +180 -0
- data/app/views/lean_cms/form_submissions/index.html.erb +104 -0
- data/app/views/lean_cms/form_submissions/show.html.erb +157 -0
- data/app/views/lean_cms/notification_settings/edit.html.erb +192 -0
- data/app/views/lean_cms/notifications/index.html.erb +72 -0
- data/app/views/lean_cms/notifications/show.html.erb +39 -0
- data/app/views/lean_cms/page_contents/_field_editor.html.erb +174 -0
- data/app/views/lean_cms/page_contents/edit.html.erb +428 -0
- data/app/views/lean_cms/page_contents/index.html.erb +113 -0
- data/app/views/lean_cms/password_setup/show.html.erb +35 -0
- data/app/views/lean_cms/passwords/edit.html.erb +26 -0
- data/app/views/lean_cms/passwords/new.html.erb +21 -0
- data/app/views/lean_cms/passwords_mailer/reset.html.erb +6 -0
- data/app/views/lean_cms/passwords_mailer/reset.text.erb +4 -0
- data/app/views/lean_cms/posts/_form.html.erb +118 -0
- data/app/views/lean_cms/posts/edit.html.erb +31 -0
- data/app/views/lean_cms/posts/index.html.erb +100 -0
- data/app/views/lean_cms/posts/new.html.erb +16 -0
- data/app/views/lean_cms/sessions/new.html.erb +28 -0
- data/app/views/lean_cms/settings/edit.html.erb +384 -0
- data/app/views/lean_cms/shared/_admin_bar.html.erb +85 -0
- data/app/views/lean_cms/shared/_header.html.erb +86 -0
- data/app/views/lean_cms/shared/_notifications_bell.html.erb +84 -0
- data/app/views/lean_cms/shared/_sidebar.html.erb +102 -0
- data/app/views/lean_cms/users/_form.html.erb +105 -0
- data/app/views/lean_cms/users/edit.html.erb +8 -0
- data/app/views/lean_cms/users/index.html.erb +99 -0
- data/app/views/lean_cms/users/new.html.erb +8 -0
- data/app/views/lean_cms/users_mailer/admin_triggered_password_reset.html.erb +13 -0
- data/app/views/lean_cms/users_mailer/admin_triggered_password_reset.text.erb +11 -0
- data/app/views/lean_cms/users_mailer/invitation.html.erb +13 -0
- data/app/views/lean_cms/users_mailer/invitation.text.erb +11 -0
- data/app/views/lean_cms/users_mailer/reactivation.html.erb +13 -0
- data/app/views/lean_cms/users_mailer/reactivation.text.erb +11 -0
- data/config/importmap.rb +8 -0
- data/config/routes.rb +78 -0
- data/db/migrate/20251112034030_create_lean_cms_tables.rb +131 -0
- data/db/migrate/20260513000001_create_lean_cms_auth_tables.rb +31 -0
- data/db/migrate/20260514000001_create_paper_trail_versions.rb +16 -0
- data/db/migrate/20260514000002_create_action_text_tables.rb +18 -0
- data/db/migrate/20260514000003_create_active_storage_tables.rb +45 -0
- data/db/migrate/20260514000004_create_noticed_tables.rb +27 -0
- data/lib/generators/lean_cms/demo/demo_generator.rb +54 -0
- data/lib/generators/lean_cms/demo/templates/lean_cms_structure.yml +129 -0
- data/lib/generators/lean_cms/demo/templates/pages_controller.rb +30 -0
- data/lib/generators/lean_cms/demo/templates/views/pages/about.html.erb +40 -0
- data/lib/generators/lean_cms/demo/templates/views/pages/contact.html.erb +55 -0
- data/lib/generators/lean_cms/demo/templates/views/pages/home.html.erb +31 -0
- data/lib/generators/lean_cms/install/install_generator.rb +317 -0
- data/lib/generators/lean_cms/install/templates/add_lean_cms_columns_to_users.rb.tt +7 -0
- data/lib/generators/lean_cms/install/templates/lean_cms.rb +11 -0
- data/lib/generators/lean_cms/install/templates/lean_cms_structure.yml +29 -0
- data/lib/lean_cms/configuration.rb +32 -0
- data/lib/lean_cms/engine.rb +93 -0
- data/lib/lean_cms/loader.rb +217 -0
- data/lib/lean_cms/sync_helper.rb +182 -0
- data/lib/lean_cms/version.rb +3 -0
- data/lib/lean_cms.rb +26 -0
- data/lib/tasks/lean_cms.rake +390 -0
- 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
|