ruby_cms 0.1.2 → 0.1.3

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 (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +5 -0
  3. data/app/components/ruby_cms/admin/admin_page_header.rb +2 -4
  4. data/app/components/ruby_cms/admin/admin_resource_card.rb +2 -2
  5. data/app/components/ruby_cms/admin/bulk_action_table/bulk_actions.rb +37 -24
  6. data/app/helpers/ruby_cms/content_blocks_helper.rb +7 -3
  7. data/app/helpers/ruby_cms/settings_helper.rb +19 -17
  8. data/app/models/ruby_cms/permission.rb +1 -1
  9. data/app/services/ruby_cms/analytics/report.rb +37 -3
  10. data/app/views/ruby_cms/admin/analytics/index.html.erb +103 -108
  11. data/app/views/ruby_cms/admin/analytics/page_details.html.erb +28 -32
  12. data/app/views/ruby_cms/admin/analytics/partials/_back_button.html.erb +3 -2
  13. data/app/views/ruby_cms/admin/analytics/partials/_browser_device.html.erb +34 -20
  14. data/app/views/ruby_cms/admin/analytics/partials/_daily_activity_chart.html.erb +27 -27
  15. data/app/views/ruby_cms/admin/analytics/partials/_hourly_activity_chart.html.erb +19 -19
  16. data/app/views/ruby_cms/admin/analytics/partials/_landing_pages.html.erb +21 -0
  17. data/app/views/ruby_cms/admin/analytics/partials/_os_stats.html.erb +26 -0
  18. data/app/views/ruby_cms/admin/analytics/partials/_popular_pages.html.erb +34 -0
  19. data/app/views/ruby_cms/admin/analytics/partials/_recent_activity.html.erb +16 -14
  20. data/app/views/ruby_cms/admin/analytics/partials/_security_alert.html.erb +3 -3
  21. data/app/views/ruby_cms/admin/analytics/partials/_top_referrers.html.erb +14 -14
  22. data/app/views/ruby_cms/admin/analytics/partials/_top_visitors.html.erb +28 -0
  23. data/app/views/ruby_cms/admin/analytics/partials/_utm_sources.html.erb +21 -0
  24. data/app/views/ruby_cms/admin/analytics/visitor_details.html.erb +44 -48
  25. data/lib/generators/ruby_cms/install_generator.rb +2 -1
  26. data/lib/ruby_cms/cli.rb +1 -1
  27. data/lib/ruby_cms/version.rb +1 -1
  28. data/lib/tasks/admin.rake +120 -0
  29. data/log/test.log +7284 -0
  30. metadata +7 -2
  31. data/lib/tasks/ruby_cms.rake +0 -27
@@ -7,114 +7,110 @@
7
7
  show_security = high_volume || rapid || suspicious_ua
8
8
  %>
9
9
  <%= admin_page(
10
- title: t("ruby_cms.admin.analytics.visitor_details_title", default: "Visitor details"),
11
- subtitle: "#{@start_date} → #{@end_date}",
10
+ title: @ip_address,
11
+ subtitle: "Visitor analytics · #{@start_date} → #{@end_date}",
12
12
  content_card: false
13
13
  ) do %>
14
14
  <div class="space-y-6">
15
15
  <div class="flex items-start justify-between gap-4">
16
- <div class="min-w-0">
17
- <p class="text-sm font-medium text-gray-500">IP Address</p>
18
- <p class="mt-1 text-lg font-semibold tracking-tight text-gray-900 tabular-nums truncate"><%= @ip_address %></p>
19
- </div>
20
- <div class="flex items-center gap-2 flex-shrink-0">
16
+ <div class="flex items-center gap-2">
21
17
  <%= render "ruby_cms/admin/analytics/partials/security_alert" if show_security %>
22
- <%= render "ruby_cms/admin/analytics/partials/back_button", path: ruby_cms_admin_analytics_path(start_date: @start_date, end_date: @end_date, period: @period) %>
23
18
  </div>
19
+ <%= render "ruby_cms/admin/analytics/partials/back_button", path: ruby_cms_admin_analytics_path(start_date: @start_date, end_date: @end_date, period: @period) %>
24
20
  </div>
25
21
 
26
- <div class="grid grid-cols-4 gap-4">
27
- <div class="rounded-lg border border-gray-200/80 bg-white p-5 shadow-sm">
28
- <p class="text-sm font-medium text-gray-500">Total views</p>
29
- <p class="mt-2 text-3xl font-semibold tracking-tight text-gray-900 tabular-nums"><%= number_with_delimiter(@visitor_stats[:total_views]) %></p>
22
+ <div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
23
+ <div class="rounded-lg border border-border/60 bg-white p-6 shadow-sm">
24
+ <p class="text-sm font-medium text-muted-foreground">Total views</p>
25
+ <p class="mt-2 text-2xl font-semibold tracking-tight text-foreground tabular-nums"><%= number_with_delimiter(@visitor_stats[:total_views]) %></p>
30
26
  </div>
31
- <div class="rounded-lg border border-gray-200/80 bg-white p-5 shadow-sm">
32
- <p class="text-sm font-medium text-gray-500">Pages visited</p>
33
- <p class="mt-2 text-3xl font-semibold tracking-tight text-gray-900 tabular-nums"><%= number_with_delimiter(@visitor_stats[:unique_pages]) %></p>
27
+ <div class="rounded-lg border border-border/60 bg-white p-6 shadow-sm">
28
+ <p class="text-sm font-medium text-muted-foreground">Pages visited</p>
29
+ <p class="mt-2 text-2xl font-semibold tracking-tight text-foreground tabular-nums"><%= number_with_delimiter(@visitor_stats[:unique_pages]) %></p>
34
30
  </div>
35
- <div class="rounded-lg border border-gray-200/80 bg-white p-5 shadow-sm">
36
- <p class="text-sm font-medium text-gray-500">First visit</p>
37
- <p class="mt-2 text-base font-semibold text-gray-900"><%= @visitor_stats[:first_visit].present? ? l(@visitor_stats[:first_visit], format: :short) : "—" %></p>
31
+ <div class="rounded-lg border border-border/60 bg-white p-6 shadow-sm">
32
+ <p class="text-sm font-medium text-muted-foreground">First visit</p>
33
+ <p class="mt-2 text-base font-semibold text-foreground"><%= @visitor_stats[:first_visit].present? ? l(@visitor_stats[:first_visit], format: :short) : "—" %></p>
38
34
  </div>
39
- <div class="rounded-lg border border-gray-200/80 bg-white p-5 shadow-sm">
40
- <p class="text-sm font-medium text-gray-500">Last visit</p>
41
- <p class="mt-2 text-base font-semibold text-gray-900"><%= @visitor_stats[:last_visit].present? ? l(@visitor_stats[:last_visit], format: :short) : "—" %></p>
35
+ <div class="rounded-lg border border-border/60 bg-white p-6 shadow-sm">
36
+ <p class="text-sm font-medium text-muted-foreground">Last visit</p>
37
+ <p class="mt-2 text-base font-semibold text-foreground"><%= @visitor_stats[:last_visit].present? ? l(@visitor_stats[:last_visit], format: :short) : "—" %></p>
42
38
  </div>
43
39
  </div>
44
40
 
45
41
  <% first_visit_record = Ahoy::Visit.where(ip: @ip_address, started_at: @start_date.beginning_of_day..@end_date.end_of_day).order(started_at: :asc).first %>
46
42
  <% has_meta = first_visit_record && (first_visit_record.os.present? || first_visit_record.device_type.present? || first_visit_record.browser.present? || (first_visit_record.respond_to?(:landing_page) && first_visit_record.landing_page.present?)) %>
47
43
  <% if has_meta %>
48
- <div class="rounded-lg border border-gray-200/80 bg-white p-6 shadow-sm">
49
- <p class="text-sm font-semibold text-gray-900">Environment</p>
50
- <div class="mt-4 grid grid-cols-4 gap-4">
44
+ <div class="rounded-lg border border-border/60 bg-white p-6 shadow-sm">
45
+ <p class="text-sm font-semibold text-foreground">Environment</p>
46
+ <div class="mt-4 grid grid-cols-2 lg:grid-cols-4 gap-4">
51
47
  <% if first_visit_record.os.present? %>
52
48
  <div>
53
- <p class="text-xs font-medium text-gray-500">OS</p>
54
- <p class="mt-1 text-sm font-medium text-gray-900"><%= first_visit_record.os %></p>
49
+ <p class="text-xs font-medium text-muted-foreground">OS</p>
50
+ <p class="mt-1 text-sm font-medium text-foreground"><%= first_visit_record.os %></p>
55
51
  </div>
56
52
  <% end %>
57
53
  <% if first_visit_record.device_type.present? %>
58
54
  <div>
59
- <p class="text-xs font-medium text-gray-500">Device</p>
60
- <p class="mt-1 text-sm font-medium text-gray-900"><%= first_visit_record.device_type %></p>
55
+ <p class="text-xs font-medium text-muted-foreground">Device</p>
56
+ <p class="mt-1 text-sm font-medium text-foreground"><%= first_visit_record.device_type %></p>
61
57
  </div>
62
58
  <% end %>
63
59
  <% if first_visit_record.browser.present? %>
64
60
  <div>
65
- <p class="text-xs font-medium text-gray-500">Browser</p>
66
- <p class="mt-1 text-sm font-medium text-gray-900"><%= first_visit_record.browser %></p>
61
+ <p class="text-xs font-medium text-muted-foreground">Browser</p>
62
+ <p class="mt-1 text-sm font-medium text-foreground"><%= first_visit_record.browser %></p>
67
63
  </div>
68
64
  <% end %>
69
65
  <% if first_visit_record.respond_to?(:landing_page) && first_visit_record.landing_page.present? %>
70
- <div class="col-span-4">
71
- <p class="text-xs font-medium text-gray-500">Landing</p>
72
- <p class="mt-1 text-sm font-medium text-gray-900 truncate" title="<%= first_visit_record.landing_page %>"><%= truncate(first_visit_record.landing_page, length: 120) %></p>
66
+ <div class="col-span-full">
67
+ <p class="text-xs font-medium text-muted-foreground">Landing page</p>
68
+ <p class="mt-1 text-sm font-medium text-foreground truncate" title="<%= first_visit_record.landing_page %>"><%= truncate(first_visit_record.landing_page, length: 120) %></p>
73
69
  </div>
74
70
  <% end %>
75
71
  </div>
76
72
  </div>
77
73
  <% end %>
78
74
 
79
- <div class="rounded-lg border border-gray-200/80 bg-white shadow-sm overflow-hidden">
80
- <div class="px-6 py-4 border-b border-gray-100">
81
- <p class="text-sm font-semibold text-gray-900">Activity</p>
82
- <p class="text-sm text-gray-500">Pages visited by this IP.</p>
75
+ <div class="rounded-lg border border-border/60 bg-white shadow-sm overflow-hidden">
76
+ <div class="px-6 py-4 border-b border-border/40">
77
+ <p class="text-sm font-semibold text-foreground">Activity</p>
78
+ <p class="text-sm text-muted-foreground">Pages visited by this IP.</p>
83
79
  </div>
84
80
 
85
81
  <div class="overflow-x-auto">
86
82
  <table class="min-w-full text-sm">
87
- <thead class="bg-gray-50">
88
- <tr class="text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
83
+ <thead class="bg-muted/30">
84
+ <tr class="text-left text-xs font-medium uppercase tracking-wider text-muted-foreground">
89
85
  <th class="px-6 py-3">Page</th>
90
86
  <th class="px-6 py-3">Browser</th>
91
87
  <th class="px-6 py-3">Referrer</th>
92
88
  <th class="px-6 py-3">Time</th>
93
89
  </tr>
94
90
  </thead>
95
- <tbody class="divide-y divide-gray-100">
91
+ <tbody class="divide-y divide-border/40">
96
92
  <% if @visitor_views.any? %>
97
93
  <% @visitor_views.each do |event| %>
98
94
  <% visit = event.visit %>
99
- <tr class="hover:bg-gray-50 transition-colors">
100
- <td class="px-6 py-3 font-medium text-gray-900">
95
+ <tr class="hover:bg-muted/30 transition-colors">
96
+ <td class="px-6 py-3 font-medium text-foreground">
101
97
  <% page_name = event.respond_to?(:page_name) ? event.page_name : nil %>
102
98
  <% if page_name.present? %>
103
99
  <%= link_to page_name,
104
100
  page_details_ruby_cms_admin_analytics_path(page_name:, start_date: @start_date, end_date: @end_date, period: @period),
105
- class: "text-gray-900 hover:underline" %>
101
+ class: "text-primary hover:underline" %>
106
102
  <% else %>
107
- <span class="text-gray-500">Unknown</span>
103
+ <span class="text-muted-foreground">Unknown</span>
108
104
  <% end %>
109
105
  </td>
110
- <td class="px-6 py-3 text-gray-700"><%= visit&.browser || "Unknown" %></td>
111
- <td class="px-6 py-3 text-gray-700 truncate max-w-[26rem]"><%= visit&.referrer.presence || "Direct" %></td>
112
- <td class="px-6 py-3 text-gray-700"><%= time_ago_in_words(event.time) %> ago</td>
106
+ <td class="px-6 py-3 text-foreground"><%= visit&.browser || "Unknown" %></td>
107
+ <td class="px-6 py-3 text-foreground truncate max-w-[26rem]"><%= visit&.referrer.presence || "Direct" %></td>
108
+ <td class="px-6 py-3 text-muted-foreground"><%= time_ago_in_words(event.time) %> ago</td>
113
109
  </tr>
114
110
  <% end %>
115
111
  <% else %>
116
112
  <tr>
117
- <td colspan="4" class="px-6 py-10 text-center text-sm text-gray-500">No activity recorded for this visitor</td>
113
+ <td colspan="4" class="px-6 py-10 text-center text-sm text-muted-foreground">No activity recorded for this visitor</td>
118
114
  </tr>
119
115
  <% end %>
120
116
  </tbody>
@@ -13,7 +13,7 @@ module RubyCms
13
13
 
14
14
  Next steps (if not already done):
15
15
  - rails db:migrate
16
- - rails ruby_cms:seed_permissions (includes manage_visitor_errors)
16
+ - rails ruby_cms:seed_permissions (includes manage_visitor_errors and manage_analytics)
17
17
  - rails ruby_cms:setup_admin (or: rails ruby_cms:grant_manage_admin email=you@example.com)
18
18
  - To seed content blocks from YAML: add content under content_blocks in config/locales/<locale>.yml, then run rails ruby_cms:content_blocks:seed (or call it from db/seeds.rb).
19
19
 
@@ -1035,6 +1035,7 @@ module RubyCms
1035
1035
  manage_permissions
1036
1036
  manage_content_blocks
1037
1037
  manage_visitor_errors
1038
+ manage_analytics
1038
1039
  ]
1039
1040
  required_permission_ids = RubyCms::Permission.where(key: required_keys).pluck(:id)
1040
1041
  return false if required_permission_ids.size != required_keys.size
data/lib/ruby_cms/cli.rb CHANGED
@@ -22,7 +22,7 @@ module RubyCms
22
22
  # Logic for the interactive first-admin setup. Uses Thor::Shell for prompts.
23
23
  class RunSetupAdmin
24
24
  ADMIN_PERMISSION_KEYS = %w[
25
- manage_admin manage_permissions manage_content_blocks manage_visitor_errors
25
+ manage_admin manage_permissions manage_content_blocks manage_visitor_errors manage_analytics
26
26
  ].freeze
27
27
 
28
28
  class << self
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyCms
4
- VERSION = "0.1.2"
4
+ VERSION = "0.1.3"
5
5
  end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :admin do
4
+ def ruby_cms_user_class
5
+ Object.const_get(Rails.application.config.ruby_cms.user_class_name.presence ||
6
+ "User")
7
+ end
8
+
9
+ def ruby_cms_email_attr(user_class)
10
+ user_class.column_names.include?("email_address") ? :email_address : :email
11
+ end
12
+
13
+ def ruby_cms_find_user_by_email(email)
14
+ user_class = ruby_cms_user_class
15
+ email_attr = ruby_cms_email_attr(user_class)
16
+ user_class.find_by(email_attr => email)
17
+ end
18
+
19
+ def ruby_cms_make_user_full_admin!(user)
20
+ # Prefer host app's make_admin! if it exists.
21
+ if user.respond_to?(:make_admin!) && user.respond_to?(:admin?) && !user.admin?
22
+ user.make_admin!
23
+ elsif user.class.column_names.include?("admin")
24
+ # Fallback if admin? / make_admin! aren't provided by host app.
25
+ user.update!(admin: true) unless user.respond_to?(:admin?) && user.admin?
26
+ end
27
+ end
28
+
29
+ def ruby_cms_grant_all_permissions_to!(user, email:)
30
+ RubyCms::Permission.ensure_defaults!
31
+ RubyCms::UserPermission.where(user:).destroy_all
32
+ RubyCms::Engine.grant_manage_admin_permission(user, email)
33
+ end
34
+
35
+ desc "Make a user a full admin with all permissions (no templates). Usage: rails admin:make_admin email=user@example.com"
36
+ task make_admin: :environment do
37
+ email = ENV["email"] || ENV.fetch("EMAIL", nil)
38
+ abort "Usage: rails admin:make_admin email=user@example.com" if email.blank?
39
+
40
+ user = ruby_cms_find_user_by_email(email)
41
+ abort "User not found: #{email}" unless user
42
+
43
+ ruby_cms_make_user_full_admin!(user)
44
+ ruby_cms_grant_all_permissions_to!(user, email:)
45
+
46
+ keys = RubyCms::UserPermission.where(user:)
47
+ .joins(:permission)
48
+ .pluck("permissions.key")
49
+ .sort
50
+ .uniq
51
+
52
+ puts "#{email} is now admin with #{keys.size} permission(s):"
53
+ keys.each {|k| puts " - #{k}" }
54
+ end
55
+
56
+ desc "List all users with their admin status and permission keys"
57
+ task list_users: :environment do
58
+ user_class = ruby_cms_user_class
59
+ email_attr = ruby_cms_email_attr(user_class)
60
+
61
+ users = user_class.order(email_attr)
62
+
63
+ if users.none?
64
+ puts "No users found."
65
+ next
66
+ end
67
+
68
+ users.each do |user|
69
+ keys = RubyCms::UserPermission.where(user:)
70
+ .joins(:permission)
71
+ .pluck("permissions.key")
72
+ .sort
73
+
74
+ admin_flag = user.respond_to?(:admin?) ? user.admin? : false
75
+
76
+ puts "#{user.public_send(email_attr)} admin=#{admin_flag} keys=#{keys.join(', ')}"
77
+ end
78
+ end
79
+
80
+ desc "Seed all permission keys into the database (no templates)."
81
+ task seed_permissions: :environment do
82
+ RubyCms::Permission.ensure_defaults!
83
+ RubyCms::Settings.ensure_defaults! if defined?(RubyCms::Settings)
84
+ RubyCms::Settings.import_initializer_values! if defined?(RubyCms::Settings)
85
+
86
+ puts "Permissions: #{RubyCms::Permission.pluck(:key).sort.join(', ')}"
87
+ end
88
+
89
+ desc "Delete admin user. Usage: rails admin:delete email=user@example.com"
90
+ task delete: :environment do
91
+ email = ENV["email"] || ENV.fetch("EMAIL", nil)
92
+ abort "Usage: rails admin:delete email=user@example.com" if email.blank?
93
+
94
+ user = ruby_cms_find_user_by_email(email)
95
+ abort "User not found: #{email}" unless user
96
+
97
+ print "Delete #{email}? (yes/no): "
98
+ abort "Cancelled." unless $stdin.gets.to_s.strip.downcase == "yes"
99
+
100
+ user.destroy!
101
+ puts "Deleted #{email}"
102
+ end
103
+
104
+ desc "Log out all users (delete all sessions)"
105
+ task logout_all: :environment do
106
+ abort "Session constant not found in host app. Ensure your auth generator created Session." unless defined?(Session)
107
+
108
+ count = Session.count
109
+ if count.zero?
110
+ puts "No active sessions."
111
+ next
112
+ end
113
+
114
+ print "Log out all #{count} session(s)? (yes/no): "
115
+ abort "Cancelled." unless $stdin.gets.to_s.strip.downcase == "yes"
116
+
117
+ Session.destroy_all
118
+ puts "Logged out #{count} session(s)"
119
+ end
120
+ end