sessions 0.1.3 → 0.2.2

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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -2
  3. data/README.md +26 -16
  4. data/app/controllers/sessions/application_controller.rb +4 -3
  5. data/app/helpers/sessions/engine_helper.rb +23 -0
  6. data/app/views/sessions/_devices.html.erb +2 -2
  7. data/app/views/sessions/_history.html.erb +2 -2
  8. data/docs/PRD.md +52 -52
  9. data/docs/research/{01-carhey.md → 01-host-app.md} +23 -23
  10. data/docs/research/02-ecosystem.md +1 -1
  11. data/docs/research/07-device-detection.md +13 -13
  12. data/lib/generators/sessions/install_generator.rb +1 -1
  13. data/lib/generators/sessions/madmin_generator.rb +23 -7
  14. data/lib/generators/sessions/templates/add_lifecycle_to_sessions.rb.erb +37 -0
  15. data/lib/generators/sessions/templates/add_sessions_columns.rb.erb +28 -0
  16. data/lib/generators/sessions/templates/create_sessions.rb.erb +20 -5
  17. data/lib/generators/sessions/templates/create_sessions_events.rb.erb +4 -4
  18. data/lib/generators/sessions/templates/initializer.rb +9 -8
  19. data/lib/generators/sessions/templates/madmin/session_resource.rb +24 -13
  20. data/lib/generators/sessions/templates/madmin/sessions_controller.rb +11 -7
  21. data/lib/generators/sessions/templates/madmin/user_panel/madmin_user_panel.rb +43 -0
  22. data/lib/generators/sessions/templates/madmin/user_panel/sessions.html.erb +133 -0
  23. data/lib/generators/sessions/upgrade_generator.rb +4 -1
  24. data/lib/sessions/adapters/omakase.rb +62 -18
  25. data/lib/sessions/adapters/warden.rb +172 -48
  26. data/lib/sessions/configuration.rb +14 -4
  27. data/lib/sessions/current.rb +4 -4
  28. data/lib/sessions/end_reason.rb +67 -0
  29. data/lib/sessions/models/concerns/has_sessions.rb +3 -3
  30. data/lib/sessions/models/concerns/model.rb +124 -59
  31. data/lib/sessions/models/event.rb +56 -21
  32. data/lib/sessions/version.rb +1 -1
  33. data/lib/sessions.rb +68 -14
  34. metadata +9 -5
@@ -1,6 +1,6 @@
1
1
  # Device intelligence: UA parsing, Hotwire Native, client hints, IP capture
2
2
 
3
- Researched 2026-06-11. Code citations are from read-only clones under `/tmp/sessions-research/` (browser, device_detector, hotwire-native-ios, hotwire-native-android, turbo-rails, rails-stable @ v8.1.3) and the local apps under `/Users/javi/GitHub/`. Web citations carry URL + fetch date.
3
+ Researched 2026-06-11. Code citations are from read-only clones under `/tmp/sessions-research/` (browser, device_detector, hotwire-native-ios, hotwire-native-android, turbo-rails, rails-stable @ v8.1.3) and the local apps under `/path/to/repos/`. Web citations carry URL + fetch date.
4
4
 
5
5
  ## Top findings
6
6
 
@@ -11,7 +11,7 @@ Researched 2026-06-11. Code citations are from read-only clones under `/tmp/sess
11
11
  - **Android WebView is explicitly excluded from Chrome's UA reduction** ("We don't have current plans for User-Agent Reduction on iOS and Android WebView" — chromium.org/updates/ua-reduction, fetched 2026-06-10). So Hotwire Native **Android** UAs still carry real device model + real Android version. Hotwire Native **iOS** UAs carry real iOS version but never the hardware model.
12
12
  - **Web Chrome UAs are husks since 2023**: frozen `Windows NT 10.0`, `Intel Mac OS X 10_15_7`, `Linux; Android 10; K`, minor versions `0.0.0`. Real data moved to UA Client Hints — which **only Chromium ships; Safari and Firefox still refuse as of June 2026**.
13
13
  - **iPadOS masquerades as macOS by default** since iPadOS 13 — server-side, an iPad on Safari is byte-identical to a Mac. No fix without JS.
14
- - **Our four local apps customize the UA prefix today but none embeds app version or (iOS) device model** — the gem should ship a recommended prefix convention; CarHey's native HTTP client already proves the pattern (`"CarHey Android 1.0.5 (build 6; Android 14; sdk 34; Pixel 7)"`).
14
+ - **Our four local apps customize the UA prefix today but none embeds app version or (iOS) device model** — the gem should ship a recommended prefix convention; HostApp's native HTTP client already proves the pattern (`"HostApp Android 1.0.5 (build 6; Android 14; sdk 34; Pixel 7)"`).
15
15
  - **`request.remote_ip` behind Cloudflare returns a Cloudflare edge IP** unless CF ranges are added to `trusted_proxies` (which *replaces* the private-range defaults) — document `cloudflare-rails` or an `ip_resolver` hook. Portable IP column: `string limit: 45`; `inet` is Postgres-only.
16
16
 
17
17
  ## A. Ruby UA parsers: `browser` vs `device_detector`
@@ -56,7 +56,7 @@ Researched 2026-06-11. Code citations are from read-only clones under `/tmp/sess
56
56
  1. **Always persist the raw UA in a `text` column** (no 255 limit) plus the relevant raw Client-Hint headers when present. Parsing is a *projection* that can be re-run as parsers/conventions improve. Validated by uniform prior art.
57
57
  2. **Hard dependency on `browser`** as the default web parser: MIT, zero-dep, tiny, Rails-aware, good enough for "Chrome 137 on macOS" + bot flagging. A drop-in gem needs device intel working with zero setup; an adapter-only design would gut the first-run experience.
58
58
  3. **Optional `device_detector` adapter** (auto-upgrade if the host app bundles it): better device names on legacy/Android UAs, native Client-Hints handling, much bigger bot DB. Don't hard-depend: LGPL, 1.5 MB data, 2-years-stale releases.
59
- 4. **Built-in native-app UA parser that runs first** (before any web parser): recognizes `Hotwire Native iOS|Android`, `Turbo Native`, the recommended prefix convention below, and CarHey's existing shapes. This is the gem's actual moat; no third-party parser does it.
59
+ 4. **Built-in native-app UA parser that runs first** (before any web parser): recognizes `Hotwire Native iOS|Android`, `Turbo Native`, the recommended prefix convention below, and HostApp's existing shapes. This is the gem's actual moat; no third-party parser does it.
60
60
  5. Expose `config.ua_parser = :browser | :device_detector | ->(ua, headers) { DeviceInfo.new(...) }` for escape hatches, and stamp rows with parser identity/version if cheap (optional).
61
61
 
62
62
  ## B. Hotwire Native user agents
@@ -125,17 +125,17 @@ Substring contract: `"Turbo Native"` or `"Hotwire Native"` anywhere in the UA. T
125
125
 
126
126
  | App | Prefix set | Where | Effective UA shape |
127
127
  |---|---|---|---|
128
- | carhey-ios | `"CarHey iOS; RailsFast Native iOS;"` (`{App}` from `CFBundleDisplayName`) | `RailsFast/Core/AppConfiguration.swift:10-12`, applied `RailsFast/App/AppDelegate.swift:103` | `Mozilla/5.0 (iPhone; CPU iPhone OS x_y like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CarHey iOS; RailsFast Native iOS; Hotwire Native iOS; Turbo Native iOS; bridge-components: […]` |
128
+ | hostapp-ios | `"HostApp iOS; RailsFast Native iOS;"` (`{App}` from `CFBundleDisplayName`) | `RailsFast/Core/AppConfiguration.swift:10-12`, applied `RailsFast/App/AppDelegate.swift:103` | `Mozilla/5.0 (iPhone; CPU iPhone OS x_y like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) HostApp iOS; RailsFast Native iOS; Hotwire Native iOS; Turbo Native iOS; bridge-components: […]` |
129
129
  | railsfast-ios | `"RailsFast iOS; RailsFast Native iOS;"` | `RailsFast/Core/AppConfiguration.swift:10-12`, `RailsFast/App/AppDelegate.swift:93-99` | same shape, `RailsFast` tokens |
130
- | carhey-android | `"CarHey Android;"` | `app/src/main/java/com/carhey/android/CarHeyApplication.kt:169` | `CarHey Android; Hotwire Native Android; Turbo Native Android; bridge-components: […]; Mozilla/5.0 (Linux; Android NN; <Model> Build/…; wv) … Chrome/NNN.0.0.0 Mobile Safari/537.36` |
130
+ | hostapp-android | `"HostApp Android;"` | `app/src/main/java/com/hostapp/android/HostAppApplication.kt:169` | `HostApp Android; Hotwire Native Android; Turbo Native Android; bridge-components: […]; Mozilla/5.0 (Linux; Android NN; <Model> Build/…; wv) … Chrome/NNN.0.0.0 Mobile Safari/537.36` |
131
131
  | railsfast-android | `"${BuildConfig.APPLICATION_NAME} Android; RailsFast Native Android;"` | `app/src/main/java/com/railsfast/android/RailsFastApplication.kt:45-46` | same shape with two leading brand segments |
132
132
 
133
133
  So today: **no webview UA carries the app version anywhere, and iOS UAs carry no device model.** Android model/OS arrive free via the WebView default UA.
134
134
 
135
- However, CarHey's *native* (URLSession/OkHttp) calls already use a richer convention the gem should accept as prior art:
135
+ However, HostApp's *native* (URLSession/OkHttp) calls already use a richer convention the gem should accept as prior art:
136
136
 
137
- - iOS: `"\(applicationName) iOS \(version) (build \(build); iOS \(osVersion); \(resolvedModel))"` → e.g. `CarHey iOS 1.0.5 (build 6; iOS 19.5; iPhone15,2)` (`carhey-ios/RailsFast/Core/NativeHttpClient.swift:61-71`), plus headers `X-Client-Platform/-Version/-Build/-OS` (`NativeHttpClient.swift:13-17`).
138
- - Android: `"CarHey Android $versionName (build $versionCode; Android $osRelease; sdk $sdkInt; $device)"` → e.g. `CarHey Android 1.0.5 (build 6; Android 14; sdk 34; Pixel 7)` (`carhey-android/app/src/main/java/com/carhey/android/ClientHeaders.kt:66-76`; header names `:25-29`).
137
+ - iOS: `"\(applicationName) iOS \(version) (build \(build); iOS \(osVersion); \(resolvedModel))"` → e.g. `HostApp iOS 1.0.5 (build 6; iOS 19.5; iPhone15,2)` (`hostapp-ios/RailsFast/Core/NativeHttpClient.swift:61-71`), plus headers `X-Client-Platform/-Version/-Build/-OS` (`NativeHttpClient.swift:13-17`).
138
+ - Android: `"HostApp Android $versionName (build $versionCode; Android $osRelease; sdk $sdkInt; $device)"` → e.g. `HostApp Android 1.0.5 (build 6; Android 14; sdk 34; Pixel 7)` (`hostapp-android/app/src/main/java/com/hostapp/android/ClientHeaders.kt:66-76`; header names `:25-29`).
139
139
 
140
140
  ### Recommended UA convention for the gem's README
141
141
 
@@ -143,11 +143,11 @@ Use an RFC 9110-style product token as the `applicationUserAgentPrefix`, ending
143
143
 
144
144
  ```text
145
145
  <AppName>/<version> (<model>; <os> <os_version>; build <build>);
146
- e.g. CarHey/2.4.1 (iPhone15,2; iOS 19.5; build 241);
147
- e.g. CarHey/2.4.1 (Pixel 8; Android 16; build 241);
146
+ e.g. HostApp/2.4.1 (iPhone15,2; iOS 19.5; build 241);
147
+ e.g. HostApp/2.4.1 (Pixel 8; Android 16; build 241);
148
148
  ```
149
149
 
150
- Parse rule (gem-side, tolerant): `%r{(?<app>[\w .-]+)/(?<version>\d[\w.]*) \((?<fields>[^)]*)\)}` with semicolon-split, order-insensitive fields; also accept CarHey's space-separated legacy `"CarHey iOS 1.0.5 (build 6; …)"`. Everything else (Hotwire markers, WebView UA) stays intact, so `hotwire_native_app?` and bridge components keep working.
150
+ Parse rule (gem-side, tolerant): `%r{(?<app>[\w .-]+)/(?<version>\d[\w.]*) \((?<fields>[^)]*)\)}` with semicolon-split, order-insensitive fields; also accept HostApp's space-separated legacy `"HostApp iOS 1.0.5 (build 6; …)"`. Everything else (Hotwire markers, WebView UA) stays intact, so `hotwire_native_app?` and bridge components keep working.
151
151
 
152
152
  README client snippets:
153
153
 
@@ -156,7 +156,7 @@ README client snippets:
156
156
  ```swift
157
157
  var u = utsname(); uname(&u)
158
158
  let model = withUnsafeBytes(of: &u.machine) { String(decoding: $0.prefix(while: { $0 != 0 }), as: UTF8.self) }
159
- Hotwire.config.applicationUserAgentPrefix = "CarHey/\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0") (\(model); iOS \(UIDevice.current.systemVersion));"
159
+ Hotwire.config.applicationUserAgentPrefix = "HostApp/\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0") (\(model); iOS \(UIDevice.current.systemVersion));"
160
160
  ```
161
161
 
162
162
  (`model` is `"iPhone15,2"` on device, `"arm64"` on Simulator — fine for production traffic.)
@@ -165,7 +165,7 @@ Hotwire.config.applicationUserAgentPrefix = "CarHey/\(Bundle.main.infoDictionary
165
165
 
166
166
  ```kotlin
167
167
  Hotwire.config.applicationUserAgentPrefix =
168
- "CarHey/${BuildConfig.VERSION_NAME} " +
168
+ "HostApp/${BuildConfig.VERSION_NAME} " +
169
169
  "(${Build.MODEL}; Android ${Build.VERSION.RELEASE}; build ${BuildConfig.VERSION_CODE});"
170
170
  ```
171
171
 
@@ -181,7 +181,7 @@ module Sessions
181
181
  say " schedule: every day at 4am"
182
182
 
183
183
  say "\nEvery login now lands on the devices page and in the trail:"
184
- say " current_user.sessions.active # live devices, revocable"
184
+ say " current_user.sessions.live # live devices, revocable"
185
185
  say " current_user.session_history # the trail — logins, failures, revocations"
186
186
  say "\nEvery session, every device, every login — tracked. 🔐✨\n", :green
187
187
  end
@@ -20,6 +20,9 @@ module Sessions
20
20
 
21
21
  desc "Generate Madmin resources + controllers for sessions and the login trail"
22
22
 
23
+ class_option :user_panel, type: :boolean, default: false,
24
+ desc: "Also generate a per-user Sessions panel concern and view"
25
+
23
26
  def check_for_madmin!
24
27
  return if madmin_available?
25
28
 
@@ -28,7 +31,7 @@ module Sessions
28
31
 
29
32
  This generator produces Madmin resources for the session registry and
30
33
  the login trail. For other admin frameworks, build on the same
31
- primitives it uses: Session.active / session.revoke! /
34
+ primitives it uses: Session.live / session.revoke! /
32
35
  Sessions::Event scopes (failed_logins, last_24_hours, …).
33
36
  MSG
34
37
  end
@@ -43,6 +46,13 @@ module Sessions
43
46
  template "session_events_controller.rb", "app/controllers/madmin/session_events_controller.rb"
44
47
  end
45
48
 
49
+ def create_user_panel
50
+ return unless options[:user_panel]
51
+
52
+ copy_file "user_panel/madmin_user_panel.rb", "app/controllers/concerns/sessions/madmin_user_panel.rb"
53
+ copy_file "user_panel/sessions.html.erb", "app/views/madmin/users/sessions.html.erb"
54
+ end
55
+
46
56
  def display_post_install_message
47
57
  say "\n🔐 Madmin resources for sessions installed.", :green
48
58
  say "\nTo complete the setup:"
@@ -70,13 +80,19 @@ module Sessions
70
80
  say " end"
71
81
 
72
82
  say "\n 3. (Optional) For a per-user panel (devices + trail on the user's"
73
- say " show page), add a member action to your users controller that"
74
- say " loads `user.sessions.by_recency` and `user.session_history.recent`"
75
- say " — the README's Admin section has the full recipe."
83
+ say " show page), re-run with --user-panel, include"
84
+ say " `Sessions::MadminUserPanel` in Madmin::UsersController, and add:"
85
+ say ""
86
+ say " resources :users do"
87
+ say " member do"
88
+ say " get :sessions"
89
+ say " post :revoke_all_sessions"
90
+ say " end"
91
+ say " end"
76
92
 
77
- say "\nRevoking from the index destroys the row: that device is signed out"
78
- say "on its very next request, and the revocation lands in the trail with"
79
- say "admin attribution. 🔐\n", :green
93
+ say "\nRevoking from the index ends the row in place: that device is signed out"
94
+ say "on its very next matching request, and the revocation lands in the trail"
95
+ say "with admin attribution. 🔐\n", :green
80
96
  end
81
97
 
82
98
  private
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ # v0.2 upgrade: session rows are lifecycle records now. A live device is
4
+ # `ended_at: nil`; explicit ending state lives on the row instead of being
5
+ # inferred from a missing row plus a sessions_events tombstone.
6
+ class AddSessionsLifecycleTo<%= table_name.camelize %> < ActiveRecord::Migration<%= migration_version %>
7
+ def change
8
+ change_table :<%= table_name %>, bulk: true do |t|
9
+ t.datetime :ended_at unless column_exists?(:<%= table_name %>, :ended_at)
10
+ t.string :ended_reason unless column_exists?(:<%= table_name %>, :ended_reason)
11
+ t.string :ended_by_type unless column_exists?(:<%= table_name %>, :ended_by_type)
12
+ t.send(reference_column_type, :ended_by_id) unless column_exists?(:<%= table_name %>, :ended_by_id)
13
+ t.send(json_column_type, :ended_metadata) unless column_exists?(:<%= table_name %>, :ended_metadata)
14
+ end
15
+
16
+ add_index :<%= table_name %>, :ended_at unless index_exists?(:<%= table_name %>, :ended_at)
17
+ add_index :<%= table_name %>, :ended_reason unless index_exists?(:<%= table_name %>, :ended_reason)
18
+ add_index :<%= table_name %>, %i[ended_by_type ended_by_id] unless index_exists?(:<%= table_name %>, %i[ended_by_type ended_by_id])
19
+ end
20
+
21
+ private
22
+
23
+ def reference_column_type
24
+ config = Rails.configuration.generators
25
+ case config.options[config.orm][:primary_key_type]
26
+ when nil, :bigserial then :bigint
27
+ when :serial then :integer
28
+ else config.options[config.orm][:primary_key_type]
29
+ end
30
+ end
31
+
32
+ def json_column_type
33
+ return :jsonb if connection.adapter_name.match?(/postg/i)
34
+
35
+ :json
36
+ end
37
+ end
@@ -64,6 +64,16 @@ class AddSessionsColumnsTo<%= table_name.camelize %> < ActiveRecord::Migration<%
64
64
  # Session.sweep recommendation always needed.
65
65
  t.datetime :last_seen_at
66
66
  t.string :last_seen_ip, limit: 45 # refreshed with the touch (roaming devices)
67
+
68
+ # Lifecycle state. `ended_at: nil` is the live-device set; ended rows
69
+ # remain as auditable history. Events are the trail, not the source of
70
+ # truth for whether a session should still be accepted.
71
+ t.datetime :ended_at
72
+ t.string :ended_reason
73
+ # The explicit index below keeps the generated name consistent across
74
+ # fresh installs and upgrades. Avoid the implicit duplicate index.
75
+ t.references :ended_by, polymorphic: true, type: foreign_key_type, index: false
76
+ t.send(json_column_type, :ended_metadata)
67
77
  end
68
78
 
69
79
  add_index :<%= table_name %>, :device_id
@@ -73,10 +83,28 @@ class AddSessionsColumnsTo<%= table_name.camelize %> < ActiveRecord::Migration<%
73
83
  add_index :<%= table_name %>, :auth_provider
74
84
  add_index :<%= table_name %>, :country_code
75
85
  add_index :<%= table_name %>, :last_seen_at
86
+ add_index :<%= table_name %>, :ended_at
87
+ add_index :<%= table_name %>, :ended_reason
88
+ add_index :<%= table_name %>, %i[ended_by_type ended_by_id]
76
89
  end
77
90
 
78
91
  private
79
92
 
93
+ def foreign_key_type
94
+ config = Rails.configuration.generators
95
+ reference_column_type(config.options[config.orm][:primary_key_type])
96
+ end
97
+
98
+ # Reference columns hold VALUES of the PK type, so serial pseudo-types map to
99
+ # their plain integer equivalents.
100
+ def reference_column_type(setting)
101
+ case setting
102
+ when nil, :bigserial then :bigint
103
+ when :serial then :integer
104
+ else setting
105
+ end
106
+ end
107
+
80
108
  # :jsonb on PostgreSQL, :json elsewhere — same adaptive pattern as the
81
109
  # rest of the gem ecosystem (chats, api_keys, …). match? (not equality):
82
110
  # PostGIS apps report adapter_name "PostGIS" and are PostgreSQL too.
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # The Rails-8-shaped sessions table for a Devise app: one row = one
4
- # signed-in device, destroyed on logout/revocation (rows = active sessions;
5
- # history lives in sessions_events). Deliberately the SAME base shape
3
+ # The Rails-8-shaped sessions table for a Devise app: one row = one signed-in
4
+ # device lifecycle (`ended_at: nil` means live; history lives in
5
+ # sessions_events). Deliberately the SAME base shape
6
6
  # `rails generate authentication` creates — so if you ever migrate from
7
7
  # Devise to Rails auth, your sessions table is already waiting.
8
8
  class Create<%= table_name.camelize %> < ActiveRecord::Migration<%= migration_version %>
@@ -23,8 +23,8 @@ class Create<%= table_name.camelize %> < ActiveRecord::Migration<%= migration_ve
23
23
 
24
24
  # Devise/Warden mode: SHA-256 of a random token whose raw value lives
25
25
  # ONLY in the user's own session (OWASP: never persist raw session
26
- # identifiers). The Warden adapter validates it on every request
27
- # destroy the row and that device is signed out on its next request.
26
+ # identifiers). The Warden adapter validates it on every request; only
27
+ # a matching token plus explicit lifecycle end may sign that device out.
28
28
  t.string :token_digest
29
29
 
30
30
  # The Warden scope ("user") — multi-scope Devise apps.
@@ -72,6 +72,18 @@ class Create<%= table_name.camelize %> < ActiveRecord::Migration<%= migration_ve
72
72
  t.datetime :last_seen_at
73
73
  t.string :last_seen_ip, limit: 45
74
74
 
75
+ # Lifecycle state. v0.2 keeps rows as the source of truth instead of
76
+ # deleting them and asking events to act as tombstones. `ended_at: nil`
77
+ # is the live-device set; explicit ended_reason values are the only
78
+ # thing a Devise/Warden request is allowed to enforce.
79
+ t.datetime :ended_at
80
+ t.string :ended_reason
81
+ # The explicit index below keeps the generated name consistent across
82
+ # create-table and upgrade paths. `references` indexes by default, so
83
+ # disable that implicit index to avoid two identical indexes.
84
+ t.references :ended_by, polymorphic: true, type: foreign_key_type, index: false
85
+ t.send(json_column_type, :ended_metadata)
86
+
75
87
  t.timestamps
76
88
  end
77
89
 
@@ -82,6 +94,9 @@ class Create<%= table_name.camelize %> < ActiveRecord::Migration<%= migration_ve
82
94
  add_index :<%= table_name %>, :auth_provider
83
95
  add_index :<%= table_name %>, :country_code
84
96
  add_index :<%= table_name %>, :last_seen_at
97
+ add_index :<%= table_name %>, :ended_at
98
+ add_index :<%= table_name %>, :ended_reason
99
+ add_index :<%= table_name %>, %i[ended_by_type ended_by_id]
85
100
  end
86
101
 
87
102
  private
@@ -23,10 +23,10 @@ class CreateSessionsEvents < ActiveRecord::Migration<%= migration_version %>
23
23
  t.references :authenticatable, polymorphic: true, type: foreign_key_type, index: false
24
24
  t.string :scope
25
25
 
26
- # The trail ↔ registry linkage. A plain column, NO foreign key: the
27
- # registry row it points at gets destroyed on revoke; history must
28
- # survive. A suspicious login here is one lookup away from revoking
29
- # the live session it created.
26
+ # The trail ↔ registry linkage. A plain column, NO foreign key:
27
+ # lifecycle rows are normally ended in place, but account-erasure and
28
+ # legacy host deletes may still remove them. A suspicious login here
29
+ # is one lookup away from revoking the live session it created.
30
30
  t.send(session_id_column_type, :session_id)
31
31
 
32
32
  t.string :identity # email-as-typed (normalized), even for unknown accounts
@@ -122,8 +122,8 @@ Sessions.configure do |config|
122
122
  # "Was this you?" email here. Not fired on a user's very first login.
123
123
  #
124
124
  # PASS THE EVENT to your mailer, not the session: the event is a
125
- # persisted, GlobalID-able record that survives revocation (the session
126
- # row may be destroyed before an async job runs) and already carries
125
+ # persisted, GlobalID-able record that survives revocation and account
126
+ # erasure cleanup, and already carries
127
127
  # everything the email needs — event.user, event.device_name,
128
128
  # event.location, event.country_flag, event.occurred_at.
129
129
  #
@@ -136,11 +136,12 @@ Sessions.configure do |config|
136
136
  # end
137
137
  #
138
138
  # Someone crossed the repeated_failed_logins threshold (see BEHAVIOR
139
- # above). The identity is the email AS TYPED it may match no account,
140
- # so resolve it yourself before notifying:
139
+ # above). The event still stores no account link for failures; user: is a
140
+ # best-effort in-memory resolution for this hook and nil for unknown
141
+ # identities:
141
142
  #
142
- # config.on_repeated_failed_logins = ->(identity:, count:, event:) do
143
- # user = User.find_by(email: identity) or next
143
+ # config.on_repeated_failed_logins = ->(identity:, count:, event:, user:) do
144
+ # next unless user
144
145
  # SecurityMailer.with(user: user, event: event).repeated_failed_logins.deliver_later
145
146
  # end
146
147
  #
@@ -172,10 +173,10 @@ Sessions.configure do |config|
172
173
  # config.authenticate_method = :authenticate_user!
173
174
  #
174
175
  # Render the devices page with a specific layout (nil inherits whatever
175
- # your parent controller uses set this if your signed-in surfaces use a
176
- # different layout, e.g. "app"):
176
+ # your parent controller uses). Use a callable when web/native shells differ:
177
177
  #
178
178
  # config.layout = nil
179
+ # config.layout = ->(controller) { controller.hotwire_native_app? ? "app" : "web" }
179
180
  #
180
181
  # Optional sudo gate before destructive actions on the devices page (ASVS
181
182
  # 3.3.4's "having re-entered login credentials"). The action runs only
@@ -1,9 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Live device registry (sessions gem): one row = one signed-in device,
4
- # destroyed on logout/revocation so this index IS the set of sessions that
5
- # can act on your app right now. The append-only history lives in
6
- # Sessions::Event (see Sessions::EventResource).
3
+ # Device lifecycle registry (sessions gem): one row = one signed-in device.
4
+ # `ended_at: nil` is the live set that can act on your app right now; ended
5
+ # rows stay around as durable revocation state until account erasure or
6
+ # explicit host cleanup removes them. The append-only history lives in
7
+ # Sessions::Event.
7
8
  class SessionResource < Madmin::Resource
8
9
  model <%= session_class %>
9
10
 
@@ -30,6 +31,8 @@ class SessionResource < Madmin::Resource
30
31
  attribute :app_build, index: false, form: false
31
32
  attribute :user_agent, index: false, form: false
32
33
  attribute :last_seen_at, index: true, form: false, label: "Last seen"
34
+ attribute :ended_at, index: true, form: false, label: "Ended"
35
+ attribute :ended_reason, index: true, form: false, label: "Ended because"
33
36
  attribute :created_at, index: true, form: false, label: "Signed in"
34
37
  attribute :updated_at, show: false, form: false
35
38
 
@@ -39,8 +42,14 @@ class SessionResource < Madmin::Resource
39
42
  attribute :adoption_key, show: false, form: false
40
43
  attribute :auth_detail, show: false, form: false
41
44
  attribute :client_hints, show: false, form: false
45
+ attribute :ended_by_type, show: false, form: false
46
+ attribute :ended_by_id, show: false, form: false
47
+ attribute :ended_metadata, show: false, form: false
42
48
 
43
- # Gem scopes: active = last activity within 30 days.
49
+ # Gem scopes: live/ended are lifecycle state; active/inactive are UI
50
+ # grouping within the live set (last activity within 30 days).
51
+ scope :live
52
+ scope :ended
44
53
  scope :active
45
54
  scope :inactive
46
55
 
@@ -53,14 +62,16 @@ class SessionResource < Madmin::Resource
53
62
  def self.default_sort_column = "created_at"
54
63
  def self.default_sort_direction = "desc"
55
64
 
56
- # Remote logout: destroys the row (the device is signed out on its next
57
- # request), writes the `revoked` trail event attributed to the admin, and
58
- # rotates the user's remember-me credentials in Devise mode.
65
+ # Remote logout: ends the row in place, writes the `revoked` trail event
66
+ # attributed to the admin, and rotates the user's remember-me credentials
67
+ # in Devise mode. The device is signed out on its next matching request.
59
68
  member_action do
60
- button_to "Revoke session",
61
- main_app.revoke_madmin_session_path(@record),
62
- method: :post,
63
- class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-red-700 shadow-sm ring-1 ring-inset ring-red-300 hover:bg-red-50",
64
- data: { turbo_confirm: "Revoke this session? The device will be signed out on its next request." }
69
+ if @record.live?
70
+ button_to "Revoke session",
71
+ main_app.revoke_madmin_session_path(@record),
72
+ method: :post,
73
+ class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-red-700 shadow-sm ring-1 ring-inset ring-red-300 hover:bg-red-50",
74
+ data: { turbo_confirm: "Revoke this session? The device will be signed out on its next request." }
75
+ end
65
76
  end
66
77
  end
@@ -2,20 +2,24 @@
2
2
 
3
3
  module Madmin
4
4
  class SessionsController < Madmin::ResourceController
5
- # Admin remote logout. `revoke!` destroys the row (the device is kicked
6
- # on its next request — the cookie session and, in Devise mode, any
7
- # remember-me revival), and writes the immutable `revoked` trail event
8
- # with `by:` attribution.
5
+ # Admin remote logout. `revoke!` ends the row in place (the device is
6
+ # kicked on its next matching request — the cookie session and, in Devise
7
+ # mode, any remember-me revival), and writes the immutable `revoked`
8
+ # trail event with `by:` attribution.
9
9
  def revoke
10
10
  session_row = <%= session_class %>.find(params[:id])
11
11
  device = session_row.device_name
12
12
 
13
- session_row.revoke!(reason: :admin_revoked, by: current_user)
14
- flash[:notice] = "Session revoked (#{device}). The device will be signed out on its next request."
13
+ if session_row.live?
14
+ session_row.revoke!(reason: :admin_revoked, by: current_user)
15
+ flash[:notice] = "Session revoked (#{device}). The device will be signed out on its next request."
16
+ else
17
+ flash[:alert] = "That session had already ended (#{device}). There is no live device to sign out."
18
+ end
15
19
 
16
20
  redirect_back fallback_location: main_app.madmin_sessions_path
17
21
  rescue ActiveRecord::RecordNotFound
18
- flash[:alert] = "That session no longer exists (probably already revoked)."
22
+ flash[:alert] = "That session no longer exists."
19
23
  redirect_back fallback_location: main_app.madmin_sessions_path
20
24
  end
21
25
 
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sessions
4
+ # Optional per-user Madmin panel actions. Include this in
5
+ # Madmin::UsersController, then draw the routes printed by
6
+ # `rails generate sessions:madmin --user-panel`.
7
+ module MadminUserPanel
8
+ extend ActiveSupport::Concern
9
+
10
+ SESSION_EVENTS_PER_PAGE = 30
11
+
12
+ def sessions
13
+ @user = User.find(params[:id])
14
+ @sessions = @user.sessions.live.by_recency.to_a
15
+ events = @user.session_history.recent
16
+
17
+ @failed_last_24h = events.failed_logins.last_24_hours.count
18
+ if respond_to?(:pagy, true)
19
+ @pagy, @events = pagy(events, limit: SESSION_EVENTS_PER_PAGE)
20
+ else
21
+ @events = events.limit(SESSION_EVENTS_PER_PAGE)
22
+ end
23
+ rescue StandardError => e
24
+ raise unless defined?(::Pagy::OptionError) && e.is_a?(::Pagy::OptionError)
25
+
26
+ @pagy, @events = pagy(events, page: 1, limit: SESSION_EVENTS_PER_PAGE)
27
+ end
28
+
29
+ # Account-takeover response: revoke every live row with admin attribution.
30
+ # Each row writes its own `revoked` event and, in Devise mode, rotates
31
+ # remember-me credentials so a long-lived cookie cannot revive the device.
32
+ def revoke_all_sessions
33
+ user = User.find(params[:id])
34
+ count = user.sessions.live.count
35
+
36
+ user.revoke_all_sessions!(by: current_user)
37
+ flash[:notice] = "#{count} #{"session".pluralize(count)} revoked. " \
38
+ "Every device will be signed out on its next request."
39
+
40
+ redirect_to main_app.sessions_madmin_user_path(user)
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,133 @@
1
+ <% content_for :title, "Sessions - #{@user.try(:email) || @user.try(:email_address) || @user.id}" %>
2
+
3
+ <div class="mx-auto w-full max-w-6xl px-4 py-6">
4
+ <header class="mb-6 flex flex-wrap items-center justify-between gap-4">
5
+ <div>
6
+ <p class="text-sm text-gray-500">
7
+ <%= link_to "Users", madmin_users_path, class: "hover:text-gray-700" %>
8
+ <span class="mx-1 text-gray-400">/</span>
9
+ <%= link_to @user.try(:email) || @user.try(:email_address) || @user.id, madmin_user_path(@user), class: "hover:text-gray-700" %>
10
+ </p>
11
+ <h1 class="mt-1 text-2xl font-semibold text-gray-900">Sessions &amp; devices</h1>
12
+ </div>
13
+
14
+ <div class="flex items-center gap-2">
15
+ <%= link_to "Back to user", madmin_user_path(@user),
16
+ class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-700 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" %>
17
+ <% if @sessions.any? %>
18
+ <%= button_to "Revoke all sessions", revoke_all_sessions_madmin_user_path(@user),
19
+ method: :post,
20
+ class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-red-700 shadow-sm ring-1 ring-inset ring-red-300 hover:bg-red-50",
21
+ data: { turbo_confirm: "Revoke ALL #{@sessions.size} sessions? Every device will be signed out on its next request." } %>
22
+ <% end %>
23
+ </div>
24
+ </header>
25
+
26
+ <div class="mb-6 grid grid-cols-1 gap-4 sm:grid-cols-3">
27
+ <div class="rounded-lg border border-gray-200 bg-white p-4">
28
+ <p class="text-xs font-medium uppercase text-gray-500">Live sessions</p>
29
+ <p class="mt-1 text-2xl font-semibold text-gray-900"><%= @sessions.size %></p>
30
+ </div>
31
+ <div class="rounded-lg border border-gray-200 bg-white p-4">
32
+ <p class="text-xs font-medium uppercase text-gray-500">Last activity</p>
33
+ <p class="mt-1 text-2xl font-semibold text-gray-900">
34
+ <% if (latest = @sessions.filter_map(&:last_active_at).max) %>
35
+ <%= time_ago_in_words(latest) %> ago
36
+ <% else %>
37
+ -
38
+ <% end %>
39
+ </p>
40
+ </div>
41
+ <div class="rounded-lg border border-gray-200 bg-white p-4">
42
+ <p class="text-xs font-medium uppercase text-gray-500">Failed logins (24h)</p>
43
+ <p class="mt-1 text-2xl font-semibold <%= @failed_last_24h.positive? ? "text-red-700" : "text-gray-900" %>">
44
+ <%= @failed_last_24h %>
45
+ </p>
46
+ </div>
47
+ </div>
48
+
49
+ <section class="mb-6 overflow-hidden rounded-lg border border-gray-200 bg-white">
50
+ <header class="border-b border-gray-200 px-6 py-4">
51
+ <h2 class="text-base font-semibold text-gray-900">Live devices</h2>
52
+ </header>
53
+
54
+ <% if @sessions.any? %>
55
+ <ul class="divide-y divide-gray-100">
56
+ <% @sessions.each do |session_row| %>
57
+ <li class="flex items-start justify-between gap-4 px-6 py-4">
58
+ <div class="min-w-0">
59
+ <p class="text-sm font-medium text-gray-900">
60
+ <%= link_to session_row.device_name, madmin_session_path(session_row), class: "hover:underline" %>
61
+ </p>
62
+ <p class="mt-1 text-xs text-gray-500">
63
+ <% bits = [] %>
64
+ <% bits << session_row.location if session_row.location %>
65
+ <% bits << "IP #{session_row.last_seen_ip || session_row.ip_address}" if session_row.last_seen_ip || session_row.ip_address %>
66
+ <% bits << "via #{session_row.auth_method_label}" if session_row.auth_method_label %>
67
+ <% bits << "last seen #{time_ago_in_words(session_row.last_active_at)} ago" if session_row.last_active_at %>
68
+ <%= bits.join(" - ") %>
69
+ </p>
70
+ </div>
71
+
72
+ <%= button_to "Revoke", revoke_madmin_session_path(session_row),
73
+ method: :post,
74
+ class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-red-700 shadow-sm ring-1 ring-inset ring-red-300 hover:bg-red-50",
75
+ data: { turbo_confirm: "Revoke #{session_row.device_name}?" } %>
76
+ </li>
77
+ <% end %>
78
+ </ul>
79
+ <% else %>
80
+ <div class="px-6 py-10 text-center text-sm text-gray-500">No live sessions.</div>
81
+ <% end %>
82
+ </section>
83
+
84
+ <section class="overflow-hidden rounded-lg border border-gray-200 bg-white">
85
+ <header class="border-b border-gray-200 px-6 py-4">
86
+ <h2 class="text-base font-semibold text-gray-900">Login activity</h2>
87
+ </header>
88
+
89
+ <% if @events.any? %>
90
+ <div class="overflow-x-auto">
91
+ <table class="min-w-full divide-y divide-gray-200 text-sm">
92
+ <thead class="bg-gray-50 text-xs uppercase text-gray-500">
93
+ <tr>
94
+ <th class="px-4 py-2 text-left font-medium">When</th>
95
+ <th class="px-4 py-2 text-left font-medium">Event</th>
96
+ <th class="px-4 py-2 text-left font-medium">Device</th>
97
+ <th class="px-4 py-2 text-left font-medium">IP / Location</th>
98
+ <th class="px-4 py-2 text-left font-medium">Via</th>
99
+ </tr>
100
+ </thead>
101
+ <tbody class="divide-y divide-gray-100">
102
+ <% @events.each do |event| %>
103
+ <tr>
104
+ <td class="whitespace-nowrap px-4 py-3 align-top">
105
+ <%= l(event.occurred_at, format: :short) %>
106
+ </td>
107
+ <td class="px-4 py-3 align-top">
108
+ <%= event.label %>
109
+ <% if event.reason %>
110
+ <div class="text-xs text-gray-500"><%= event.reason_label %></div>
111
+ <% end %>
112
+ </td>
113
+ <td class="px-4 py-3 align-top"><%= event.device_name %></td>
114
+ <td class="whitespace-nowrap px-4 py-3 align-top">
115
+ <%= [ event.ip_address, event.location ].compact.join(" - ").presence || "-" %>
116
+ </td>
117
+ <td class="px-4 py-3 align-top"><%= event.auth_method_label || "-" %></td>
118
+ </tr>
119
+ <% end %>
120
+ </tbody>
121
+ </table>
122
+ </div>
123
+
124
+ <% if defined?(@pagy) && @pagy.respond_to?(:last) && @pagy.last > 1 %>
125
+ <div class="border-t border-gray-100 bg-gray-50 px-6 py-3">
126
+ <%= render "madmin/shared/pagination", pagy: @pagy %>
127
+ </div>
128
+ <% end %>
129
+ <% else %>
130
+ <div class="px-6 py-10 text-center text-sm text-gray-500">No login activity.</div>
131
+ <% end %>
132
+ </section>
133
+ </div>