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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +5 -0
- data/app/components/ruby_cms/admin/admin_page_header.rb +2 -4
- data/app/components/ruby_cms/admin/admin_resource_card.rb +2 -2
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_actions.rb +37 -24
- data/app/helpers/ruby_cms/content_blocks_helper.rb +7 -3
- data/app/helpers/ruby_cms/settings_helper.rb +19 -17
- data/app/models/ruby_cms/permission.rb +1 -1
- data/app/services/ruby_cms/analytics/report.rb +37 -3
- data/app/views/ruby_cms/admin/analytics/index.html.erb +103 -108
- data/app/views/ruby_cms/admin/analytics/page_details.html.erb +28 -32
- data/app/views/ruby_cms/admin/analytics/partials/_back_button.html.erb +3 -2
- data/app/views/ruby_cms/admin/analytics/partials/_browser_device.html.erb +34 -20
- data/app/views/ruby_cms/admin/analytics/partials/_daily_activity_chart.html.erb +27 -27
- data/app/views/ruby_cms/admin/analytics/partials/_hourly_activity_chart.html.erb +19 -19
- data/app/views/ruby_cms/admin/analytics/partials/_landing_pages.html.erb +21 -0
- data/app/views/ruby_cms/admin/analytics/partials/_os_stats.html.erb +26 -0
- data/app/views/ruby_cms/admin/analytics/partials/_popular_pages.html.erb +34 -0
- data/app/views/ruby_cms/admin/analytics/partials/_recent_activity.html.erb +16 -14
- data/app/views/ruby_cms/admin/analytics/partials/_security_alert.html.erb +3 -3
- data/app/views/ruby_cms/admin/analytics/partials/_top_referrers.html.erb +14 -14
- data/app/views/ruby_cms/admin/analytics/partials/_top_visitors.html.erb +28 -0
- data/app/views/ruby_cms/admin/analytics/partials/_utm_sources.html.erb +21 -0
- data/app/views/ruby_cms/admin/analytics/visitor_details.html.erb +44 -48
- data/lib/generators/ruby_cms/install_generator.rb +2 -1
- data/lib/ruby_cms/cli.rb +1 -1
- data/lib/ruby_cms/version.rb +1 -1
- data/lib/tasks/admin.rake +120 -0
- data/log/test.log +7284 -0
- metadata +7 -2
- 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:
|
|
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="
|
|
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-
|
|
28
|
-
<p class="text-sm font-medium text-
|
|
29
|
-
<p class="mt-2 text-
|
|
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-
|
|
32
|
-
<p class="text-sm font-medium text-
|
|
33
|
-
<p class="mt-2 text-
|
|
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-
|
|
36
|
-
<p class="text-sm font-medium text-
|
|
37
|
-
<p class="mt-2 text-base font-semibold text-
|
|
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-
|
|
40
|
-
<p class="text-sm font-medium text-
|
|
41
|
-
<p class="mt-2 text-base font-semibold text-
|
|
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-
|
|
49
|
-
<p class="text-sm font-semibold text-
|
|
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-
|
|
54
|
-
<p class="mt-1 text-sm font-medium text-
|
|
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-
|
|
60
|
-
<p class="mt-1 text-sm font-medium text-
|
|
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-
|
|
66
|
-
<p class="mt-1 text-sm font-medium text-
|
|
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-
|
|
71
|
-
<p class="text-xs font-medium text-
|
|
72
|
-
<p class="mt-1 text-sm font-medium text-
|
|
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-
|
|
80
|
-
<div class="px-6 py-4 border-b border-
|
|
81
|
-
<p class="text-sm font-semibold text-
|
|
82
|
-
<p class="text-sm text-
|
|
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-
|
|
88
|
-
<tr class="text-left text-xs font-
|
|
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-
|
|
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-
|
|
100
|
-
<td class="px-6 py-3 font-medium text-
|
|
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-
|
|
101
|
+
class: "text-primary hover:underline" %>
|
|
106
102
|
<% else %>
|
|
107
|
-
<span class="text-
|
|
103
|
+
<span class="text-muted-foreground">Unknown</span>
|
|
108
104
|
<% end %>
|
|
109
105
|
</td>
|
|
110
|
-
<td class="px-6 py-3 text-
|
|
111
|
-
<td class="px-6 py-3 text-
|
|
112
|
-
<td class="px-6 py-3 text-
|
|
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-
|
|
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
|
data/lib/ruby_cms/version.rb
CHANGED
|
@@ -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
|