argus-trail 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +176 -0
  4. data/Rakefile +6 -0
  5. data/app/controllers/argus/trail/application_controller.rb +45 -0
  6. data/app/controllers/argus/trail/audit_entries_controller.rb +19 -0
  7. data/app/controllers/argus/trail/permissions_controller.rb +65 -0
  8. data/app/controllers/argus/trail/roles_controller.rb +73 -0
  9. data/app/helpers/argus/trail/application_helper.rb +34 -0
  10. data/app/models/argus/trail/application_record.rb +7 -0
  11. data/app/models/argus/trail/audit_entry.rb +38 -0
  12. data/app/models/argus/trail/permission.rb +11 -0
  13. data/app/models/argus/trail/role.rb +53 -0
  14. data/app/models/argus/trail/role_assignment.rb +13 -0
  15. data/app/models/argus/trail/role_permission.rb +10 -0
  16. data/app/views/argus/trail/audit_entries/index.html.erb +94 -0
  17. data/app/views/argus/trail/pagination/_simple_pager.html.erb +9 -0
  18. data/app/views/argus/trail/permissions/_form.html.erb +29 -0
  19. data/app/views/argus/trail/permissions/edit.html.erb +10 -0
  20. data/app/views/argus/trail/permissions/index.html.erb +47 -0
  21. data/app/views/argus/trail/permissions/new.html.erb +10 -0
  22. data/app/views/argus/trail/permissions/show.html.erb +27 -0
  23. data/app/views/argus/trail/roles/_form.html.erb +50 -0
  24. data/app/views/argus/trail/roles/edit.html.erb +10 -0
  25. data/app/views/argus/trail/roles/index.html.erb +68 -0
  26. data/app/views/argus/trail/roles/new.html.erb +10 -0
  27. data/app/views/argus/trail/roles/show.html.erb +52 -0
  28. data/app/views/layouts/argus/trail/application.html.erb +38 -0
  29. data/config/routes.rb +7 -0
  30. data/lib/argus/trail/actor.rb +67 -0
  31. data/lib/argus/trail/configuration.rb +46 -0
  32. data/lib/argus/trail/current.rb +7 -0
  33. data/lib/argus/trail/engine.rb +13 -0
  34. data/lib/argus/trail/pagination.rb +41 -0
  35. data/lib/argus/trail/version.rb +5 -0
  36. data/lib/argus/trail.rb +30 -0
  37. data/lib/generators/argus/trail/config/config_generator.rb +21 -0
  38. data/lib/generators/argus/trail/install/install_generator.rb +46 -0
  39. data/lib/generators/argus/trail/install/templates/POST_INSTALL +43 -0
  40. data/lib/generators/argus/trail/install/templates/create_argus_trail_tables.rb.erb +47 -0
  41. data/lib/generators/argus/trail/install/templates/initializer.rb +40 -0
  42. data/lib/generators/argus/trail/views/views_generator.rb +18 -0
  43. metadata +105 -0
@@ -0,0 +1,94 @@
1
+ <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
2
+ <div class="flex items-center justify-between mb-8">
3
+ <div>
4
+ <h1 class="text-2xl font-bold text-gray-900">Audit Log</h1>
5
+ <p class="text-sm text-gray-500 mt-1">Every role assignment and permission grant/revoke, in order</p>
6
+ </div>
7
+ </div>
8
+
9
+ <% if params[:subject_type].present? || params[:subject_id].present? || params[:role_id].present? %>
10
+ <div class="mb-4 flex items-center gap-2 bg-blue-50 border border-blue-200 rounded-lg px-4 py-2.5 text-sm text-blue-800">
11
+ Filtered view.
12
+ <%= link_to "Clear filter", audit_entries_path, class: "ml-2 text-blue-600 hover:underline font-medium" %>
13
+ </div>
14
+ <% end %>
15
+
16
+ <div class="grid grid-cols-2 sm:grid-cols-3 gap-4 mb-6">
17
+ <div class="bg-white rounded-xl border border-gray-200 p-4 text-center shadow-sm">
18
+ <p class="text-2xl font-bold text-gray-900"><%= Argus::Trail::AuditEntry.count %></p>
19
+ <p class="text-xs text-gray-500 mt-0.5">Total Events</p>
20
+ </div>
21
+ <div class="bg-white rounded-xl border border-gray-200 p-4 text-center shadow-sm">
22
+ <p class="text-2xl font-bold text-blue-600"><%= Argus::Trail::AuditEntry.where("created_at >= ?", Time.zone.today.beginning_of_day).count %></p>
23
+ <p class="text-xs text-gray-500 mt-0.5">Today</p>
24
+ </div>
25
+ <div class="bg-white rounded-xl border border-gray-200 p-4 text-center shadow-sm">
26
+ <p class="text-2xl font-bold text-gray-900"><%= Argus::Trail::AuditEntry.select(:subject_type, :subject_id).distinct.count %></p>
27
+ <p class="text-xs text-gray-500 mt-0.5">Distinct Subjects</p>
28
+ </div>
29
+ </div>
30
+
31
+ <div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
32
+ <% if @pages.any? %>
33
+ <table class="min-w-full divide-y divide-gray-200">
34
+ <thead class="bg-gray-50">
35
+ <tr>
36
+ <th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Event</th>
37
+ <th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Subject</th>
38
+ <th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Change</th>
39
+ <th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Changed By</th>
40
+ <th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">When</th>
41
+ </tr>
42
+ </thead>
43
+ <tbody class="bg-white divide-y divide-gray-100">
44
+ <% @pages.each do |entry| %>
45
+ <tr class="hover:bg-gray-50 transition-colors">
46
+ <td class="px-6 py-4 whitespace-nowrap">
47
+ <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold bg-gray-100 text-gray-700">
48
+ <%= event_type_label(entry.event_type) %>
49
+ </span>
50
+ </td>
51
+
52
+ <td class="px-6 py-4 text-sm text-gray-800">
53
+ <%= link_to entry.subject_label, audit_entries_path(subject_type: entry.subject_type, subject_id: entry.subject_id), class: "text-blue-600 hover:underline" %>
54
+ </td>
55
+
56
+ <td class="px-6 py-4">
57
+ <% if %w[role_assigned role_revoked].include?(entry.event_type) %>
58
+ <div class="flex items-center gap-2 text-sm text-gray-700">
59
+ <span class="<%= entry.event_type == 'role_assigned' ? 'text-green-700' : 'text-red-700' %> font-medium">
60
+ <%= entry.event_type == "role_assigned" ? "Assigned" : "Revoked" %>
61
+ </span>
62
+ <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold <%= role_badge_class(entry.role_name) %>"><%= entry.role_name %></span>
63
+ </div>
64
+ <% else %>
65
+ <div class="text-sm text-gray-700">
66
+ <span class="<%= entry.event_type == 'permission_granted' ? 'text-green-700' : 'text-red-700' %> font-medium">
67
+ <%= entry.event_type == "permission_granted" ? "Granted" : "Revoked" %>
68
+ </span>
69
+ “<%= entry.permission_name&.humanize %>” on role <%= entry.role_name %>
70
+ </div>
71
+ <% end %>
72
+ </td>
73
+
74
+ <td class="px-6 py-4 text-sm text-gray-600"><%= entry.changed_by_label %></td>
75
+
76
+ <td class="px-6 py-4 whitespace-nowrap">
77
+ <p class="text-sm text-gray-700"><%= entry.created_at.strftime("%d %b %Y") %></p>
78
+ <p class="text-xs text-gray-400"><%= entry.created_at.strftime("%I:%M %p") %></p>
79
+ </td>
80
+ </tr>
81
+ <% end %>
82
+ </tbody>
83
+ </table>
84
+
85
+ <div class="px-6 py-4 border-t border-gray-100 flex justify-center">
86
+ <%= argus_trail_paginate(@pages) %>
87
+ </div>
88
+ <% else %>
89
+ <div class="text-center py-16">
90
+ <p class="text-gray-500 text-sm">No audit events recorded yet.</p>
91
+ </div>
92
+ <% end %>
93
+ </div>
94
+ </div>
@@ -0,0 +1,9 @@
1
+ <% if pages.respond_to?(:total_pages) && pages.total_pages > 1 %>
2
+ <div class="flex items-center justify-center gap-3 text-sm py-2">
3
+ <%= link_to "← Prev", url_for(request.query_parameters.merge(page: [ pages.current_page - 1, 1 ].max)),
4
+ class: "px-3 py-1.5 rounded-md border border-gray-300 hover:bg-gray-50 #{'pointer-events-none opacity-40' if pages.current_page <= 1}" %>
5
+ <span class="text-gray-500">Page <%= pages.current_page %> of <%= pages.total_pages %></span>
6
+ <%= link_to "Next →", url_for(request.query_parameters.merge(page: [ pages.current_page + 1, pages.total_pages ].min)),
7
+ class: "px-3 py-1.5 rounded-md border border-gray-300 hover:bg-gray-50 #{'pointer-events-none opacity-40' if pages.current_page >= pages.total_pages}" %>
8
+ </div>
9
+ <% end %>
@@ -0,0 +1,29 @@
1
+ <%= form_with model: permission, url: (permission.persisted? ? permission_path(permission) : permissions_path), class: "space-y-6" do |f| %>
2
+ <% if permission.errors.any? %>
3
+ <div class="bg-red-50 border-l-4 border-red-400 p-4 rounded-lg">
4
+ <p class="text-sm font-semibold text-red-700 mb-1"><%= pluralize(permission.errors.count, "error") %> prevented saving:</p>
5
+ <ul class="list-disc list-inside space-y-0.5">
6
+ <% permission.errors.full_messages.each do |msg| %>
7
+ <li class="text-sm text-red-600"><%= msg %></li>
8
+ <% end %>
9
+ </ul>
10
+ </div>
11
+ <% end %>
12
+
13
+ <div>
14
+ <label class="block text-sm font-semibold text-gray-700 mb-1">Permission Name <span class="text-red-500">*</span></label>
15
+ <%= f.text_field :name, placeholder: "e.g. manage_users",
16
+ class: "block w-full px-3 py-2 border border-gray-300 rounded-lg text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" %>
17
+ </div>
18
+
19
+ <div>
20
+ <label class="block text-sm font-semibold text-gray-700 mb-1">Description <span class="text-red-500">*</span></label>
21
+ <%= f.text_area :description, rows: 2,
22
+ class: "block w-full px-3 py-2 border border-gray-300 rounded-lg text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none" %>
23
+ </div>
24
+
25
+ <div class="flex items-center gap-3 pt-2 border-t border-gray-100">
26
+ <%= f.submit class: "px-5 py-2 bg-blue-600 text-white text-sm font-semibold rounded-lg hover:bg-blue-700 cursor-pointer" %>
27
+ <%= link_to "Cancel", permissions_path, class: "px-5 py-2 bg-white text-gray-700 text-sm font-semibold border border-gray-300 rounded-lg hover:bg-gray-50" %>
28
+ </div>
29
+ <% end %>
@@ -0,0 +1,10 @@
1
+ <div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
2
+ <div class="flex items-center gap-4 mb-8">
3
+ <%= link_to "← Back", permission_path(@permission), class: "text-gray-400 hover:text-gray-600" %>
4
+ <h1 class="text-2xl font-bold text-gray-900">Edit Permission: <%= @permission.name.humanize %></h1>
5
+ </div>
6
+
7
+ <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
8
+ <%= render "form", permission: @permission %>
9
+ </div>
10
+ </div>
@@ -0,0 +1,47 @@
1
+ <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
2
+ <div class="flex items-center justify-between mb-8">
3
+ <div>
4
+ <h1 class="text-2xl font-bold text-gray-900">Permissions</h1>
5
+ <p class="text-sm text-gray-500 mt-1">Manage the permissions roles can be granted</p>
6
+ </div>
7
+ <%= link_to "New Permission", new_permission_path, class: "inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors" %>
8
+ </div>
9
+
10
+ <div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
11
+ <% if @permissions.any? %>
12
+ <table class="min-w-full divide-y divide-gray-200">
13
+ <thead class="bg-gray-50">
14
+ <tr>
15
+ <th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Permission</th>
16
+ <th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Description</th>
17
+ <th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Roles</th>
18
+ <th class="px-6 py-3 text-right text-xs font-semibold text-gray-500 uppercase tracking-wider">Actions</th>
19
+ </tr>
20
+ </thead>
21
+ <tbody class="bg-white divide-y divide-gray-200">
22
+ <% @permissions.each do |permission| %>
23
+ <tr class="hover:bg-gray-50 transition-colors">
24
+ <td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900"><%= permission.name.humanize %></td>
25
+ <td class="px-6 py-4 text-sm text-gray-600"><%= permission.description %></td>
26
+ <td class="px-6 py-4 text-sm text-gray-600"><%= permission.roles.count %></td>
27
+ <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
28
+ <div class="flex items-center justify-end gap-3">
29
+ <%= link_to "View", permission_path(permission), class: "text-blue-600 hover:text-blue-800" %>
30
+ <%= link_to "Edit", edit_permission_path(permission), class: "text-yellow-600 hover:text-yellow-800" %>
31
+ <%= button_to "Delete", permission_path(permission), method: :delete,
32
+ data: { turbo_confirm: "Delete permission '#{permission.name}'?" },
33
+ class: "text-red-600 hover:text-red-800 bg-transparent border-0 p-0 cursor-pointer font-medium" %>
34
+ </div>
35
+ </td>
36
+ </tr>
37
+ <% end %>
38
+ </tbody>
39
+ </table>
40
+ <% else %>
41
+ <div class="text-center py-16">
42
+ <p class="text-gray-500 text-sm">No permissions yet.</p>
43
+ <%= link_to "Create Permission", new_permission_path, class: "mt-4 inline-block text-blue-600 hover:underline text-sm font-medium" %>
44
+ </div>
45
+ <% end %>
46
+ </div>
47
+ </div>
@@ -0,0 +1,10 @@
1
+ <div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
2
+ <div class="flex items-center gap-4 mb-8">
3
+ <%= link_to "← Back", permissions_path, class: "text-gray-400 hover:text-gray-600" %>
4
+ <h1 class="text-2xl font-bold text-gray-900">New Permission</h1>
5
+ </div>
6
+
7
+ <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
8
+ <%= render "form", permission: @permission %>
9
+ </div>
10
+ </div>
@@ -0,0 +1,27 @@
1
+ <div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
2
+ <div class="flex items-center justify-between mb-8">
3
+ <div class="flex items-center gap-4">
4
+ <%= link_to "← Back", permissions_path, class: "text-gray-400 hover:text-gray-600" %>
5
+ <div>
6
+ <h1 class="text-2xl font-bold text-gray-900"><%= @permission.name.humanize %></h1>
7
+ <p class="text-sm text-gray-500 mt-0.5"><%= @permission.description %></p>
8
+ </div>
9
+ </div>
10
+ <%= link_to "Edit", edit_permission_path(@permission), class: "inline-flex items-center px-4 py-2 bg-yellow-500 text-white text-sm font-medium rounded-lg hover:bg-yellow-600" %>
11
+ </div>
12
+
13
+ <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
14
+ <h2 class="text-base font-semibold text-gray-900 mb-4">Roles with this permission (<%= @permission.roles.count %>)</h2>
15
+ <% if @permission.roles.any? %>
16
+ <div class="flex flex-wrap gap-2">
17
+ <% @permission.roles.order(:name).each do |role| %>
18
+ <%= link_to role_path(role), class: "#{role_badge_class(role.name)} inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold" do %>
19
+ <%= role.name %>
20
+ <% end %>
21
+ <% end %>
22
+ </div>
23
+ <% else %>
24
+ <p class="text-sm text-gray-400 italic">No roles have this permission yet.</p>
25
+ <% end %>
26
+ </div>
27
+ </div>
@@ -0,0 +1,50 @@
1
+ <%= form_with model: role, url: (role.persisted? ? role_path(role) : roles_path), class: "space-y-6" do |f| %>
2
+ <% if role.errors.any? %>
3
+ <div class="bg-red-50 border-l-4 border-red-400 p-4 rounded-lg">
4
+ <p class="text-sm font-semibold text-red-700 mb-1"><%= pluralize(role.errors.count, "error") %> prevented saving:</p>
5
+ <ul class="list-disc list-inside space-y-0.5">
6
+ <% role.errors.full_messages.each do |msg| %>
7
+ <li class="text-sm text-red-600"><%= msg %></li>
8
+ <% end %>
9
+ </ul>
10
+ </div>
11
+ <% end %>
12
+
13
+ <div>
14
+ <label class="block text-sm font-semibold text-gray-700 mb-1">Role Name <span class="text-red-500">*</span></label>
15
+ <%= f.text_field :name, class: "block w-full px-3 py-2 border border-gray-300 rounded-lg text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" %>
16
+ </div>
17
+
18
+ <div>
19
+ <label class="block text-sm font-semibold text-gray-700 mb-1">Description <span class="text-red-500">*</span></label>
20
+ <%= f.text_area :description, rows: 2,
21
+ class: "block w-full px-3 py-2 border border-gray-300 rounded-lg text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none" %>
22
+ </div>
23
+
24
+ <div>
25
+ <label class="block text-sm font-semibold text-gray-700 mb-3">Permissions</label>
26
+ <div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
27
+ <% @permissions.each do |permission| %>
28
+ <label class="flex items-start gap-3 p-3 border border-gray-200 rounded-lg hover:border-indigo-300 hover:bg-indigo-50/50 cursor-pointer">
29
+ <input type="checkbox" name="permission_ids[]" value="<%= permission.id %>"
30
+ <%= "checked" if role.permission_ids.include?(permission.id) %>
31
+ class="mt-0.5 h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
32
+ <div>
33
+ <p class="text-sm font-medium text-gray-800"><%= permission.name.humanize %></p>
34
+ <p class="text-xs text-gray-500 mt-0.5"><%= permission.description %></p>
35
+ </div>
36
+ </label>
37
+ <% end %>
38
+ </div>
39
+ <% if @permissions.empty? %>
40
+ <p class="text-sm text-gray-400 italic mt-2">
41
+ No permissions exist yet. <%= link_to "Create one first", new_permission_path %>.
42
+ </p>
43
+ <% end %>
44
+ </div>
45
+
46
+ <div class="flex items-center gap-3 pt-2 border-t border-gray-100">
47
+ <%= f.submit class: "px-5 py-2 bg-blue-600 text-white text-sm font-semibold rounded-lg hover:bg-blue-700 cursor-pointer" %>
48
+ <%= link_to "Cancel", roles_path, class: "px-5 py-2 bg-white text-gray-700 text-sm font-semibold border border-gray-300 rounded-lg hover:bg-gray-50" %>
49
+ </div>
50
+ <% end %>
@@ -0,0 +1,10 @@
1
+ <div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
2
+ <div class="flex items-center gap-4 mb-8">
3
+ <%= link_to "← Back", role_path(@role), class: "text-gray-400 hover:text-gray-600" %>
4
+ <h1 class="text-2xl font-bold text-gray-900">Edit Role: <%= @role.name %></h1>
5
+ </div>
6
+
7
+ <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
8
+ <%= render "form", role: @role %>
9
+ </div>
10
+ </div>
@@ -0,0 +1,68 @@
1
+ <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
2
+ <div class="flex items-center justify-between mb-8">
3
+ <div>
4
+ <h1 class="text-2xl font-bold text-gray-900">Roles</h1>
5
+ <p class="text-sm text-gray-500 mt-1">Manage roles and their permissions</p>
6
+ </div>
7
+ <%= link_to "New Role", new_role_path, class: "inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors" %>
8
+ </div>
9
+
10
+ <div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
11
+ <% if @roles.any? %>
12
+ <table class="min-w-full divide-y divide-gray-200">
13
+ <thead class="bg-gray-50">
14
+ <tr>
15
+ <th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Role</th>
16
+ <th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Description</th>
17
+ <th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Permissions</th>
18
+ <th class="px-6 py-3 text-right text-xs font-semibold text-gray-500 uppercase tracking-wider">Actions</th>
19
+ </tr>
20
+ </thead>
21
+ <tbody class="bg-white divide-y divide-gray-200">
22
+ <% @roles.each do |role| %>
23
+ <tr class="hover:bg-gray-50 transition-colors">
24
+ <td class="px-6 py-4 whitespace-nowrap">
25
+ <span class="<%= role_badge_class(role.name) %> inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold">
26
+ <%= role.name %>
27
+ </span>
28
+ </td>
29
+ <td class="px-6 py-4 text-sm text-gray-600"><%= role.description %></td>
30
+ <td class="px-6 py-4">
31
+ <div class="flex flex-wrap gap-1">
32
+ <% if role.permissions.any? %>
33
+ <% role.permissions.first(3).each do |perm| %>
34
+ <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-indigo-50 text-indigo-700 border border-indigo-100">
35
+ <%= perm.name.humanize %>
36
+ </span>
37
+ <% end %>
38
+ <% if role.permissions.count > 3 %>
39
+ <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-600">
40
+ +<%= role.permissions.count - 3 %> more
41
+ </span>
42
+ <% end %>
43
+ <% else %>
44
+ <span class="text-xs text-gray-400 italic">No permissions</span>
45
+ <% end %>
46
+ </div>
47
+ </td>
48
+ <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
49
+ <div class="flex items-center justify-end gap-3">
50
+ <%= link_to "View", role_path(role), class: "text-blue-600 hover:text-blue-800" %>
51
+ <%= link_to "Edit", edit_role_path(role), class: "text-yellow-600 hover:text-yellow-800" %>
52
+ <%= button_to "Delete", role_path(role), method: :delete,
53
+ data: { turbo_confirm: "Delete role '#{role.name}'? This cannot be undone." },
54
+ class: "text-red-600 hover:text-red-800 bg-transparent border-0 p-0 cursor-pointer font-medium" %>
55
+ </div>
56
+ </td>
57
+ </tr>
58
+ <% end %>
59
+ </tbody>
60
+ </table>
61
+ <% else %>
62
+ <div class="text-center py-16">
63
+ <p class="text-gray-500 text-sm">No roles yet.</p>
64
+ <%= link_to "Create Role", new_role_path, class: "mt-4 inline-block text-blue-600 hover:underline text-sm font-medium" %>
65
+ </div>
66
+ <% end %>
67
+ </div>
68
+ </div>
@@ -0,0 +1,10 @@
1
+ <div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
2
+ <div class="flex items-center gap-4 mb-8">
3
+ <%= link_to "← Back", roles_path, class: "text-gray-400 hover:text-gray-600" %>
4
+ <h1 class="text-2xl font-bold text-gray-900">New Role</h1>
5
+ </div>
6
+
7
+ <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
8
+ <%= render "form", role: @role %>
9
+ </div>
10
+ </div>
@@ -0,0 +1,52 @@
1
+ <div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
2
+ <div class="flex items-center justify-between mb-8">
3
+ <div class="flex items-center gap-4">
4
+ <%= link_to "← Back", roles_path, class: "text-gray-400 hover:text-gray-600" %>
5
+ <div>
6
+ <h1 class="text-2xl font-bold text-gray-900"><%= @role.name %></h1>
7
+ <p class="text-sm text-gray-500 mt-0.5"><%= @role.description %></p>
8
+ </div>
9
+ </div>
10
+ <%= link_to "Edit Role", edit_role_path(@role), class: "inline-flex items-center px-4 py-2 bg-yellow-500 text-white text-sm font-medium rounded-lg hover:bg-yellow-600" %>
11
+ </div>
12
+
13
+ <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
14
+ <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
15
+ <h2 class="text-base font-semibold text-gray-900 mb-4">Permissions (<%= @role.permissions.count %>)</h2>
16
+ <% if @role.permissions.any? %>
17
+ <div class="space-y-2">
18
+ <% @role.permissions.order(:name).each do |perm| %>
19
+ <div class="p-3 bg-indigo-50 rounded-lg">
20
+ <p class="text-sm font-medium text-indigo-900"><%= perm.name.humanize %></p>
21
+ <p class="text-xs text-indigo-600 mt-0.5"><%= perm.description %></p>
22
+ </div>
23
+ <% end %>
24
+ </div>
25
+ <% else %>
26
+ <p class="text-sm text-gray-400 italic">No permissions assigned to this role.</p>
27
+ <% end %>
28
+ </div>
29
+
30
+ <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
31
+ <h2 class="text-base font-semibold text-gray-900 mb-4">Assigned (<%= @role.actors.count %>)</h2>
32
+ <% if @role.actors.any? %>
33
+ <div class="space-y-2">
34
+ <% @role.actors.limit(10).each do |actor| %>
35
+ <div class="p-2 rounded-lg hover:bg-gray-50 text-sm text-gray-800">
36
+ <%= actor.try(:argus_trail_display_name) || actor.try(:name) || actor.try(:email) || "##{actor.id}" %>
37
+ </div>
38
+ <% end %>
39
+ <% if @role.actors.count > 10 %>
40
+ <p class="text-xs text-gray-400 text-center pt-1">+ <%= @role.actors.count - 10 %> more</p>
41
+ <% end %>
42
+ </div>
43
+ <% else %>
44
+ <p class="text-sm text-gray-400 italic">No one has this role yet.</p>
45
+ <% end %>
46
+ </div>
47
+ </div>
48
+
49
+ <div class="mt-6">
50
+ <%= link_to "View audit history for this role", audit_entries_path(role_id: @role.id), class: "text-sm text-blue-600 hover:underline" %>
51
+ </div>
52
+ </div>
@@ -0,0 +1,38 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Role &amp; Permission Admin</title>
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <%= csrf_meta_tags %>
7
+ <%= csp_meta_tag %>
8
+
9
+ <%= yield :head %>
10
+
11
+ <!--
12
+ Self-contained by design so the engine's screens render correctly in any
13
+ host regardless of its asset pipeline/Tailwind setup. Override this
14
+ layout (or set config.layout to a host layout) via the `views` generator
15
+ if you'd rather ship your own compiled CSS.
16
+ -->
17
+ <script src="https://cdn.tailwindcss.com"></script>
18
+ </head>
19
+ <body class="bg-gray-50 min-h-screen">
20
+ <nav class="bg-white border-b border-gray-200">
21
+ <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex items-center gap-6 h-14">
22
+ <%= link_to "Roles", roles_path, class: "text-sm font-semibold text-gray-700 hover:text-blue-600" %>
23
+ <%= link_to "Permissions", permissions_path, class: "text-sm font-semibold text-gray-700 hover:text-blue-600" %>
24
+ <%= link_to "Audit Log", audit_entries_path, class: "text-sm font-semibold text-gray-700 hover:text-blue-600" %>
25
+ </div>
26
+ </nav>
27
+
28
+ <% flash.each do |type, message| %>
29
+ <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 mt-4">
30
+ <div class="rounded-lg px-4 py-2.5 text-sm <%= type.to_s == "alert" ? "bg-red-50 text-red-700 border border-red-200" : "bg-green-50 text-green-700 border border-green-200" %>">
31
+ <%= message %>
32
+ </div>
33
+ </div>
34
+ <% end %>
35
+
36
+ <%= yield %>
37
+ </body>
38
+ </html>
data/config/routes.rb ADDED
@@ -0,0 +1,7 @@
1
+ Argus::Trail::Engine.routes.draw do
2
+ resources :roles
3
+ resources :permissions
4
+ resources :audit_entries, only: [ :index ]
5
+
6
+ root to: "roles#index"
7
+ end
@@ -0,0 +1,67 @@
1
+ module Argus
2
+ module Trail
3
+ # Include in your app's user/account model to opt it into role assignment
4
+ # and permission checks:
5
+ #
6
+ # class User < ApplicationRecord
7
+ # include Argus::Trail::Actor
8
+ # end
9
+ #
10
+ # An actor can hold any number of roles (0..N) — there is no separate
11
+ # single-role mode. Assign a lone role by passing a single id to
12
+ # `sync_roles!`.
13
+ module Actor
14
+ extend ActiveSupport::Concern
15
+
16
+ included do
17
+ has_many :argus_trail_role_assignments,
18
+ as: :actor, class_name: "Argus::Trail::RoleAssignment", dependent: :destroy
19
+ has_many :roles, through: :argus_trail_role_assignments, class_name: Argus::Trail.config.role_class_name
20
+
21
+ has_many :argus_trail_audit_entries_as_subject,
22
+ as: :subject, class_name: "Argus::Trail::AuditEntry", dependent: :destroy
23
+ has_many :argus_trail_audit_entries_as_changer,
24
+ as: :changed_by, class_name: "Argus::Trail::AuditEntry", dependent: :nullify
25
+ end
26
+
27
+ def has_permission?(permission_name)
28
+ roles.joins(:permissions).exists?(Argus::Trail.config.permission_class.table_name => { name: permission_name })
29
+ end
30
+
31
+ # Diffs the requested role ids against the actor's current ones and
32
+ # writes one AuditEntry per addition/removal, so the audit log captures
33
+ # exactly which roles were assigned or revoked (not just "something changed").
34
+ def sync_roles!(new_role_ids, changed_by: Argus::Trail.config.changed_by_resolver.call)
35
+ new_ids = Array(new_role_ids).map(&:to_i).reject(&:zero?)
36
+ current_ids = role_ids
37
+
38
+ added_ids = new_ids - current_ids
39
+ removed_ids = current_ids - new_ids
40
+
41
+ transaction do
42
+ self.role_ids = new_ids
43
+
44
+ Argus::Trail.config.role_class.where(id: added_ids).find_each do |role|
45
+ argus_trail_record_role_change("role_assigned", role, changed_by)
46
+ end
47
+
48
+ Argus::Trail.config.role_class.where(id: removed_ids).find_each do |role|
49
+ argus_trail_record_role_change("role_revoked", role, changed_by)
50
+ end
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ def argus_trail_record_role_change(event_type, role, changed_by)
57
+ Argus::Trail::AuditEntry.create!(
58
+ event_type: event_type,
59
+ subject: self,
60
+ role_id: role.id,
61
+ changed_by: changed_by,
62
+ metadata: { "role_name" => role.name }
63
+ )
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,46 @@
1
+ module Argus
2
+ module Trail
3
+ class Configuration
4
+ class MissingAuthorization < StandardError
5
+ def message
6
+ "Argus::Trail could not determine how to authorize access to its admin screens. " \
7
+ "Either add Pundit to your Gemfile with Argus::Trail::Role/Permission policies, " \
8
+ "or set `config.authorize_with` in config/initializers/argus_trail.rb " \
9
+ "(e.g. `config.authorize_with = ->(controller, record) { controller.current_user&.admin? }`)."
10
+ end
11
+ end
12
+
13
+ attr_accessor :actor_class_name, :role_class_name, :permission_class_name,
14
+ :role_permission_class_name, :changed_by_resolver, :authorize_with,
15
+ :current_actor_method, :per_page, :layout
16
+
17
+ def initialize
18
+ @actor_class_name = "User"
19
+ @role_class_name = "Argus::Trail::Role"
20
+ @permission_class_name = "Argus::Trail::Permission"
21
+ @role_permission_class_name = "Argus::Trail::RolePermission"
22
+ @changed_by_resolver = -> { Argus::Trail::Current.actor }
23
+ @authorize_with = nil
24
+ @current_actor_method = :current_user
25
+ @per_page = 30
26
+ @layout = nil
27
+ end
28
+
29
+ def actor_class
30
+ actor_class_name.to_s.constantize
31
+ end
32
+
33
+ def role_class
34
+ role_class_name.to_s.constantize
35
+ end
36
+
37
+ def permission_class
38
+ permission_class_name.to_s.constantize
39
+ end
40
+
41
+ def role_permission_class
42
+ role_permission_class_name.to_s.constantize
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,7 @@
1
+ module Argus
2
+ module Trail
3
+ class Current < ActiveSupport::CurrentAttributes
4
+ attribute :actor
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,13 @@
1
+ module Argus
2
+ module Trail
3
+ class Engine < ::Rails::Engine
4
+ isolate_namespace Argus::Trail
5
+
6
+ config.generators do |g|
7
+ g.test_framework false
8
+ g.assets false
9
+ g.helper false
10
+ end
11
+ end
12
+ end
13
+ end