rails_audit_log 0.8.0 → 1.0.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 +4 -4
  2. data/README.md +294 -55
  3. data/Rakefile +5 -0
  4. data/app/assets/stylesheets/rails_audit_log/_01_base.css +32 -0
  5. data/app/assets/stylesheets/rails_audit_log/_02_layout.css +34 -0
  6. data/app/assets/stylesheets/rails_audit_log/_03_table.css +39 -0
  7. data/app/assets/stylesheets/rails_audit_log/_04_badges.css +13 -0
  8. data/app/assets/stylesheets/rails_audit_log/_05_diff.css +94 -0
  9. data/app/assets/stylesheets/rails_audit_log/_06_timeline.css +21 -0
  10. data/app/assets/stylesheets/rails_audit_log/_07_detail.css +15 -0
  11. data/app/assets/stylesheets/rails_audit_log/_08_pagination.css +56 -0
  12. data/app/assets/stylesheets/rails_audit_log/_09_filters.css +54 -0
  13. data/app/assets/stylesheets/rails_audit_log/application.css +1 -15
  14. data/app/concerns/rails_audit_log/auditable.rb +57 -0
  15. data/app/concerns/rails_audit_log/controller.rb +29 -0
  16. data/app/controllers/rails_audit_log/application_controller.rb +13 -0
  17. data/app/controllers/rails_audit_log/audit_log_entries_controller.rb +32 -0
  18. data/app/controllers/rails_audit_log/resources_controller.rb +14 -0
  19. data/app/helpers/rails_audit_log/application_helper.rb +14 -0
  20. data/app/javascript/rails_audit_log/application.js +8 -0
  21. data/app/javascript/rails_audit_log/diff_controller.js +20 -0
  22. data/app/javascript/rails_audit_log/search_controller.js +12 -0
  23. data/app/jobs/rails_audit_log/application_job.rb +1 -0
  24. data/app/models/rails_audit_log/application_record.rb +1 -0
  25. data/app/models/rails_audit_log/audit_log_entry.rb +127 -25
  26. data/app/views/layouts/rails_audit_log/application.html.erb +16 -10
  27. data/app/views/rails_audit_log/audit_log_entries/_diff.html.erb +55 -0
  28. data/app/views/rails_audit_log/audit_log_entries/index.html.erb +76 -0
  29. data/app/views/rails_audit_log/audit_log_entries/show.html.erb +50 -0
  30. data/app/views/rails_audit_log/resources/show.html.erb +35 -0
  31. data/config/importmap.rb +5 -0
  32. data/config/routes.rb +3 -0
  33. data/lib/generators/rails_audit_log/initializer/templates/rails_audit_log.rb +10 -0
  34. data/lib/generators/rails_audit_log/migrate_from_paper_trail/migrate_from_paper_trail_generator.rb +21 -0
  35. data/lib/generators/rails_audit_log/migrate_from_paper_trail/templates/migrate_from_paper_trail.rb +99 -0
  36. data/lib/rails_audit_log/engine.rb +29 -0
  37. data/lib/rails_audit_log/matchers.rb +56 -0
  38. data/lib/rails_audit_log/minitest_assertions.rb +36 -0
  39. data/lib/rails_audit_log/paper_trail_compat.rb +76 -0
  40. data/lib/rails_audit_log/test_helpers.rb +20 -0
  41. data/lib/rails_audit_log/version.rb +1 -1
  42. data/lib/rails_audit_log.rb +173 -5
  43. metadata +70 -5
@@ -0,0 +1,94 @@
1
+ /* Toggle */
2
+ .ral-diff-toggle {
3
+ display: flex;
4
+ border: 1px solid var(--ral-border);
5
+ border-radius: var(--ral-radius);
6
+ overflow: hidden;
7
+ width: fit-content;
8
+ margin-bottom: 12px;
9
+ }
10
+
11
+ .ral-diff-btn {
12
+ padding: 4px 12px;
13
+ font-size: 12px;
14
+ font-weight: 500;
15
+ color: var(--ral-muted);
16
+ background: var(--ral-surface);
17
+ border: none;
18
+ cursor: pointer;
19
+ }
20
+ .ral-diff-btn + .ral-diff-btn { border-left: 1px solid var(--ral-border); }
21
+ .ral-diff-btn:hover:not(.ral-diff-btn--active) { background: var(--ral-bg); color: var(--ral-text); }
22
+ .ral-diff-btn--active { background: var(--ral-primary); color: #fff; }
23
+
24
+ /* Inline table */
25
+ .ral-diff { width: 100%; border-collapse: collapse; font-size: 13px; }
26
+
27
+ .ral-diff th {
28
+ background: var(--ral-bg);
29
+ padding: 6px 12px;
30
+ text-align: left;
31
+ font-size: 11px;
32
+ font-weight: 600;
33
+ text-transform: uppercase;
34
+ letter-spacing: 0.04em;
35
+ color: var(--ral-muted);
36
+ border-bottom: 1px solid var(--ral-border);
37
+ }
38
+
39
+ .ral-diff td { padding: 6px 12px; border-bottom: 1px solid var(--ral-border-subtle); vertical-align: top; }
40
+ .ral-diff tbody tr:last-child td { border-bottom: none; }
41
+
42
+ .ral-diff__attr { font-weight: 500; color: #333; width: 160px; }
43
+ .ral-diff__before { color: var(--ral-danger); background: #fff5f5; font-family: monospace; }
44
+ .ral-diff__after { color: var(--ral-success); background: #f0fdf4; font-family: monospace; }
45
+
46
+ /* Side-by-side panels */
47
+ .ral-diff-side {
48
+ display: grid;
49
+ grid-template-columns: 1fr 1fr;
50
+ border: 1px solid var(--ral-border);
51
+ border-radius: var(--ral-radius);
52
+ overflow: hidden;
53
+ font-size: 13px;
54
+ }
55
+
56
+ .ral-diff-side__panel--before { border-right: 1px solid var(--ral-border); }
57
+
58
+ .ral-diff-side__header {
59
+ padding: 6px 12px;
60
+ font-size: 11px;
61
+ font-weight: 600;
62
+ text-transform: uppercase;
63
+ letter-spacing: 0.04em;
64
+ color: var(--ral-muted);
65
+ border-bottom: 1px solid var(--ral-border);
66
+ background: var(--ral-bg);
67
+ }
68
+
69
+ .ral-diff-side__row {
70
+ display: flex;
71
+ gap: 8px;
72
+ padding: 6px 12px;
73
+ border-bottom: 1px solid var(--ral-border-subtle);
74
+ align-items: baseline;
75
+ }
76
+ .ral-diff-side__row:last-child { border-bottom: none; }
77
+
78
+ .ral-diff-side__attr { font-weight: 500; color: #333; min-width: 100px; flex-shrink: 0; }
79
+
80
+ .ral-diff-side__panel--before .ral-diff-side__value {
81
+ color: var(--ral-danger);
82
+ font-family: monospace;
83
+ background: #fff5f5;
84
+ padding: 1px 4px;
85
+ border-radius: 3px;
86
+ }
87
+
88
+ .ral-diff-side__panel--after .ral-diff-side__value {
89
+ color: var(--ral-success);
90
+ font-family: monospace;
91
+ background: #f0fdf4;
92
+ padding: 1px 4px;
93
+ border-radius: 3px;
94
+ }
@@ -0,0 +1,21 @@
1
+ .ral-timeline { display: flex; flex-direction: column; gap: 16px; }
2
+
3
+ .ral-timeline__entry {
4
+ background: var(--ral-surface);
5
+ border: 1px solid var(--ral-border);
6
+ border-radius: var(--ral-radius);
7
+ overflow: hidden;
8
+ box-shadow: var(--ral-shadow);
9
+ }
10
+
11
+ .ral-timeline__header {
12
+ display: flex;
13
+ align-items: center;
14
+ gap: 12px;
15
+ padding: 10px 16px;
16
+ background: var(--ral-bg);
17
+ border-bottom: 1px solid var(--ral-border);
18
+ font-size: 13px;
19
+ }
20
+
21
+ .ral-timeline__detail-link { margin-left: auto; }
@@ -0,0 +1,15 @@
1
+ .ral-card {
2
+ background: var(--ral-surface);
3
+ border: 1px solid var(--ral-border);
4
+ border-radius: var(--ral-radius);
5
+ padding: 16px;
6
+ margin-bottom: 20px;
7
+ box-shadow: var(--ral-shadow);
8
+ }
9
+
10
+ .ral-meta { display: grid; gap: 8px; }
11
+ .ral-meta__row { display: flex; gap: 16px; font-size: 13px; }
12
+ .ral-meta__row dt { width: 80px; color: var(--ral-muted); flex-shrink: 0; }
13
+ .ral-meta__row dd { color: var(--ral-text); }
14
+
15
+ .ral-entry-nav { display: flex; gap: 8px; align-items: center; }
@@ -0,0 +1,56 @@
1
+ nav.pagy.series-nav {
2
+ display: flex;
3
+ align-items: center;
4
+ gap: 4px;
5
+ margin-top: 16px;
6
+ font-size: 13px;
7
+ }
8
+
9
+ nav.pagy.series-nav a {
10
+ display: inline-flex;
11
+ align-items: center;
12
+ justify-content: center;
13
+ min-width: 32px;
14
+ height: 32px;
15
+ padding: 0 8px;
16
+ border: 1px solid var(--ral-border);
17
+ border-radius: 4px;
18
+ background: var(--ral-surface);
19
+ color: var(--ral-primary);
20
+ text-decoration: none;
21
+ }
22
+
23
+ nav.pagy.series-nav a:hover:not([aria-disabled="true"]) { background: var(--ral-bg); }
24
+
25
+ nav.pagy.series-nav a[aria-current="page"] {
26
+ background: var(--ral-primary);
27
+ color: #fff;
28
+ border-color: var(--ral-primary);
29
+ font-weight: 600;
30
+ }
31
+
32
+ nav.pagy.series-nav a[aria-disabled="true"] {
33
+ color: #aaa;
34
+ cursor: default;
35
+ border-color: var(--ral-border-subtle);
36
+ }
37
+
38
+ nav.pagy.series-nav a[role="separator"] {
39
+ border: none;
40
+ color: var(--ral-muted);
41
+ min-width: auto;
42
+ }
43
+
44
+ .ral-pagination__link {
45
+ display: inline-flex;
46
+ align-items: center;
47
+ height: 32px;
48
+ padding: 0 10px;
49
+ border: 1px solid var(--ral-border);
50
+ border-radius: 4px;
51
+ background: var(--ral-surface);
52
+ color: var(--ral-primary);
53
+ text-decoration: none;
54
+ font-size: 13px;
55
+ }
56
+ .ral-pagination__link:hover { background: var(--ral-bg); text-decoration: none; }
@@ -0,0 +1,54 @@
1
+ .ral-filters {
2
+ display: flex;
3
+ align-items: center;
4
+ gap: 8px;
5
+ flex-wrap: wrap;
6
+ margin-bottom: 16px;
7
+ }
8
+
9
+ .ral-filter-input {
10
+ padding: 5px 10px;
11
+ font-size: 13px;
12
+ border: 1px solid var(--ral-border);
13
+ border-radius: var(--ral-radius);
14
+ background: var(--ral-surface);
15
+ color: var(--ral-text);
16
+ min-width: 180px;
17
+ }
18
+ .ral-filter-input:focus { outline: 2px solid var(--ral-primary); outline-offset: -1px; }
19
+
20
+ .ral-filter-select {
21
+ padding: 5px 8px;
22
+ font-size: 13px;
23
+ border: 1px solid var(--ral-border);
24
+ border-radius: var(--ral-radius);
25
+ background: var(--ral-surface);
26
+ color: var(--ral-text);
27
+ cursor: pointer;
28
+ }
29
+
30
+ .ral-period-filter {
31
+ display: flex;
32
+ border: 1px solid var(--ral-border);
33
+ border-radius: var(--ral-radius);
34
+ overflow: hidden;
35
+ margin-left: auto;
36
+ }
37
+
38
+ .ral-period-btn {
39
+ padding: 5px 10px;
40
+ font-size: 13px;
41
+ font-weight: 500;
42
+ color: var(--ral-muted);
43
+ background: var(--ral-surface);
44
+ text-decoration: none;
45
+ }
46
+ .ral-period-btn + .ral-period-btn { border-left: 1px solid var(--ral-border); }
47
+ .ral-period-btn:hover:not(.ral-period-btn--active) {
48
+ background: var(--ral-bg);
49
+ color: var(--ral-text);
50
+ text-decoration: none;
51
+ }
52
+ .ral-period-btn--active { background: var(--ral-primary); color: #fff; }
53
+
54
+ .ral-filters__clear { font-size: 13px; }
@@ -1,15 +1 @@
1
- /*
2
- * This is a manifest file that'll be compiled into application.css, which will include all the files
3
- * listed below.
4
- *
5
- * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
6
- * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
7
- *
8
- * You're free to add application-wide styles to this file and they'll appear at the bottom of the
9
- * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
10
- * files in this directory. Styles in this file should be added after the last require_* statement.
11
- * It is generally better to create a new file per style scope.
12
- *
13
- *= require_tree .
14
- *= require_self
15
- */
1
+ /* Styles are loaded via the dashboard_stylesheets helper which globs _*.css files. */
@@ -1,4 +1,24 @@
1
1
  module RailsAuditLog
2
+ # Include in any ActiveRecord model to automatically track +create+, +update+,
3
+ # and +destroy+ events as {AuditLogEntry} records.
4
+ #
5
+ # == Basic usage
6
+ #
7
+ # class Article < ApplicationRecord
8
+ # include RailsAuditLog::Auditable
9
+ # end
10
+ #
11
+ # == Configuring tracking
12
+ #
13
+ # class Article < ApplicationRecord
14
+ # include RailsAuditLog::Auditable
15
+ # audit_log only: %i[title body],
16
+ # meta: { tenant_id: -> { Current.tenant_id } },
17
+ # version_limit: 50,
18
+ # async: true
19
+ # end
20
+ #
21
+ # Adds a polymorphic +has_many :audit_log_entries+ association to the model.
2
22
  module Auditable
3
23
  extend ActiveSupport::Concern
4
24
 
@@ -12,6 +32,8 @@ module RailsAuditLog
12
32
 
13
33
  _warn_if_audit_table_missing
14
34
 
35
+ # All {AuditLogEntry} records for this object, newest first by default.
36
+ # Destroyed when the object is destroyed.
15
37
  has_many :audit_log_entries,
16
38
  class_name: "RailsAuditLog::AuditLogEntry",
17
39
  as: :item,
@@ -54,6 +76,7 @@ module RailsAuditLog
54
76
  end
55
77
 
56
78
  class_methods do
79
+ # @api private
57
80
  def _warn_if_audit_table_missing
58
81
  return if connection.table_exists?("audit_log_entries")
59
82
 
@@ -66,6 +89,33 @@ module RailsAuditLog
66
89
  # DB not reachable during this phase (e.g. before db:create) — skip the check
67
90
  end
68
91
 
92
+ # Configures auditing options for this model. Call once in the class body
93
+ # after +include RailsAuditLog::Auditable+.
94
+ #
95
+ # @param only [Array<Symbol>, nil] whitelist of attributes to track;
96
+ # when set, all other attributes are ignored regardless of +ignore:+
97
+ # @param ignore [Array<Symbol>, nil] additional attributes to exclude on
98
+ # top of {RailsAuditLog.ignored_attributes}; ignored when +only:+ is set
99
+ # @param meta [Hash{Symbol => Proc}, nil] per-entry metadata; each value
100
+ # is a lambda called at write time — zero-argument lambdas receive no
101
+ # arguments, one-argument lambdas receive the record instance
102
+ # @param associations [Boolean, Array<Symbol>, nil] when +true+, tracks
103
+ # all +has_many+ and +has_and_belongs_to_many+ associations; pass an
104
+ # array of association names to track only specific ones
105
+ # @param version_limit [Integer, nil] maximum number of entries to retain
106
+ # per record; oldest entries are pruned after each write; overrides
107
+ # {RailsAuditLog.version_limit} for this model
108
+ # @param async [Boolean, nil] when +true+, writes are dispatched via
109
+ # +WriteAuditLogJob+; overrides {RailsAuditLog.async} for this model
110
+ # @return [void]
111
+ # @example
112
+ # class Article < ApplicationRecord
113
+ # include RailsAuditLog::Auditable
114
+ # audit_log only: %i[title body published_at],
115
+ # meta: { tenant_id: -> { Current.tenant_id } },
116
+ # associations: %i[tags],
117
+ # version_limit: 100
118
+ # end
69
119
  def audit_log(only: nil, ignore: nil, meta: nil, associations: nil, version_limit: nil, async: nil)
70
120
  self._audit_log_only = only.map(&:to_s) if only
71
121
  self._audit_log_ignore = ignore.map(&:to_s) if ignore
@@ -76,6 +126,13 @@ module RailsAuditLog
76
126
  end
77
127
  end
78
128
 
129
+ # Executes the block with audit logging disabled for this record's writes.
130
+ # A convenience wrapper around {RailsAuditLog.disable}.
131
+ #
132
+ # @yield executes the block without recording any audit entries
133
+ # @return [Object] the return value of the block
134
+ # @example Skip auditing during a bulk update
135
+ # post.skip_audit_log { post.update!(cached_at: Time.current) }
79
136
  def skip_audit_log
80
137
  RailsAuditLog.disable { yield }
81
138
  end
@@ -1,4 +1,21 @@
1
1
  module RailsAuditLog
2
+ # Include in +ApplicationController+ (or any controller) to automatically
3
+ # set and clear the current actor for every request, so that audit entries
4
+ # written during the request are tagged with the signed-in user.
5
+ #
6
+ # == Usage
7
+ #
8
+ # class ApplicationController < ActionController::Base
9
+ # include RailsAuditLog::Controller
10
+ # audit_log_actor { current_user }
11
+ # end
12
+ #
13
+ # The block passed to {audit_log_actor} is evaluated in controller instance
14
+ # context on every request via a +before_action+. The actor is cleared in an
15
+ # +after_action+ so it never leaks between requests.
16
+ #
17
+ # Request metadata (+remote_ip+, +user_agent+) is captured automatically when
18
+ # {RailsAuditLog.capture_request_metadata} is +true+.
2
19
  module Controller
3
20
  extend ActiveSupport::Concern
4
21
 
@@ -10,10 +27,22 @@ module RailsAuditLog
10
27
  end
11
28
 
12
29
  class_methods do
30
+ # Registers a block that resolves the current actor for each request.
31
+ # The block is evaluated in controller instance context, so any helper
32
+ # method available to the controller (e.g. +current_user+) can be used.
33
+ #
34
+ # @yield block evaluated in controller context; should return the actor
35
+ # @return [void]
36
+ # @example
37
+ # audit_log_actor { current_user }
38
+ #
39
+ # # With a one-argument lambda style (actor = the controller instance)
40
+ # audit_log_actor { |c| c.current_user }
13
41
  def audit_log_actor(&block)
14
42
  @audit_log_actor_block = block
15
43
  end
16
44
 
45
+ # @api private
17
46
  def audit_log_actor_block
18
47
  @audit_log_actor_block
19
48
  end
@@ -1,4 +1,17 @@
1
1
  module RailsAuditLog
2
+ # @api private
2
3
  class ApplicationController < ActionController::Base
4
+ include Pagy::Method
5
+
6
+ protect_from_forgery with: :exception
7
+ before_action :authenticate!
8
+
9
+ private
10
+
11
+ def authenticate!
12
+ return unless (auth = RailsAuditLog.authenticate)
13
+
14
+ instance_exec(self, &auth) || request_http_basic_authentication("Audit Log")
15
+ end
3
16
  end
4
17
  end
@@ -0,0 +1,32 @@
1
+ module RailsAuditLog
2
+ # @api private
3
+ class AuditLogEntriesController < ApplicationController
4
+ def index
5
+ set_filters
6
+ @pagy, @entries = pagy(filtered_scope)
7
+ @item_types = AuditLogEntry.distinct.order(:item_type).pluck(:item_type)
8
+ end
9
+
10
+ def show
11
+ @entry = AuditLogEntry.find(params[:id])
12
+ end
13
+
14
+ private
15
+
16
+ def set_filters
17
+ @event = params[:event].presence_in(AuditLogEntry::EVENTS)
18
+ @item_type = params[:item_type].presence
19
+ @period = params[:period].presence_in(AuditLogEntry::PERIODS.keys)
20
+ @q = params[:q].presence
21
+ end
22
+
23
+ def filtered_scope
24
+ scope = AuditLogEntry.order(created_at: :desc)
25
+ scope = scope.where(event: @event) if @event
26
+ scope = scope.where(item_type: @item_type) if @item_type
27
+ scope = scope.for_period(@period) if @period
28
+ scope = scope.where("whodunnit_snapshot LIKE ?", "%#{@q}%") if @q
29
+ scope
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,14 @@
1
+ module RailsAuditLog
2
+ # @api private
3
+ class ResourcesController < ApplicationController
4
+ def show
5
+ @item_type = params[:item_type]
6
+ @item_id = params[:item_id]
7
+ @pagy, @entries = pagy(
8
+ AuditLogEntry
9
+ .where(item_type: @item_type, item_id: @item_id)
10
+ .order(created_at: :asc)
11
+ )
12
+ end
13
+ end
14
+ end
@@ -1,4 +1,18 @@
1
1
  module RailsAuditLog
2
+ # @api private
2
3
  module ApplicationHelper
4
+ def format_diff_value(value)
5
+ return "—" if value.nil?
6
+ return "#{value["type"]} ##{value["id"]}" if value.is_a?(Hash)
7
+
8
+ value.inspect
9
+ end
10
+
11
+ def dashboard_stylesheets
12
+ dir = RailsAuditLog::Engine.root.join("app/assets/stylesheets/rails_audit_log")
13
+ dir.glob("_*.css").sort.map do |file|
14
+ stylesheet_link_tag("rails_audit_log/#{file.basename}", media: "all")
15
+ end.join("\n").html_safe
16
+ end
3
17
  end
4
18
  end
@@ -0,0 +1,8 @@
1
+ import "@hotwired/turbo"
2
+ import { Application } from "@hotwired/stimulus"
3
+ import SearchController from "rails_audit_log/search_controller"
4
+ import DiffController from "rails_audit_log/diff_controller"
5
+
6
+ const application = Application.start()
7
+ application.register("search", SearchController)
8
+ application.register("diff", DiffController)
@@ -0,0 +1,20 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class DiffController extends Controller {
4
+ static targets = ["inline", "side", "inlineBtn", "sideBtn"]
5
+
6
+ connect() {
7
+ this.setMode(localStorage.getItem("ral-diff-mode") || "inline")
8
+ }
9
+
10
+ setInline() { this.setMode("inline") }
11
+ setSide() { this.setMode("side") }
12
+
13
+ setMode(mode) {
14
+ localStorage.setItem("ral-diff-mode", mode)
15
+ this.inlineTarget.hidden = mode !== "inline"
16
+ this.sideTarget.hidden = mode !== "side"
17
+ this.inlineBtnTarget.classList.toggle("ral-diff-btn--active", mode === "inline")
18
+ this.sideBtnTarget.classList.toggle("ral-diff-btn--active", mode === "side")
19
+ }
20
+ }
@@ -0,0 +1,12 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class SearchController extends Controller {
4
+ filter() {
5
+ clearTimeout(this._timeout)
6
+ this._timeout = setTimeout(() => this.element.requestSubmit(), 300)
7
+ }
8
+
9
+ select() {
10
+ this.element.requestSubmit()
11
+ }
12
+ }
@@ -1,4 +1,5 @@
1
1
  module RailsAuditLog
2
+ # @api private
2
3
  class ApplicationJob < ActiveJob::Base
3
4
  end
4
5
  end
@@ -1,4 +1,5 @@
1
1
  module RailsAuditLog
2
+ # @api private
2
3
  class ApplicationRecord < ActiveRecord::Base
3
4
  self.abstract_class = true
4
5
  end