sessions 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 (71) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +61 -0
  3. data/.simplecov +54 -0
  4. data/AGENTS.md +5 -0
  5. data/Appraisals +26 -0
  6. data/CHANGELOG.md +26 -0
  7. data/CLAUDE.md +5 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +454 -0
  10. data/Rakefile +32 -0
  11. data/app/assets/stylesheets/sessions.css +50 -0
  12. data/app/controllers/sessions/application_controller.rb +159 -0
  13. data/app/controllers/sessions/devices_controller.rb +48 -0
  14. data/app/helpers/sessions/engine_helper.rb +126 -0
  15. data/app/views/sessions/_device.html.erb +40 -0
  16. data/app/views/sessions/_devices.html.erb +34 -0
  17. data/app/views/sessions/_event.html.erb +13 -0
  18. data/app/views/sessions/_history.html.erb +20 -0
  19. data/app/views/sessions/devices/history.html.erb +5 -0
  20. data/app/views/sessions/devices/index.html.erb +15 -0
  21. data/config/locales/en.yml +59 -0
  22. data/config/locales/es.yml +59 -0
  23. data/config/routes.rb +17 -0
  24. data/docs/PRD.md +743 -0
  25. data/docs/research/01-carhey.md +250 -0
  26. data/docs/research/02-ecosystem.md +261 -0
  27. data/docs/research/03-rails-core.md +220 -0
  28. data/docs/research/04-devise-warden.md +249 -0
  29. data/docs/research/05-oauth.md +193 -0
  30. data/docs/research/06-prior-art.md +312 -0
  31. data/docs/research/07-device-detection.md +250 -0
  32. data/docs/research/08-rails8-landscape.md +216 -0
  33. data/docs/research/09-market-security.md +450 -0
  34. data/gemfiles/rails_7.1.gemfile +34 -0
  35. data/gemfiles/rails_7.2.gemfile +34 -0
  36. data/gemfiles/rails_8.0.gemfile +34 -0
  37. data/gemfiles/rails_8.1.gemfile +34 -0
  38. data/lib/generators/sessions/install_generator.rb +230 -0
  39. data/lib/generators/sessions/madmin_generator.rb +95 -0
  40. data/lib/generators/sessions/templates/add_sessions_columns.rb.erb +81 -0
  41. data/lib/generators/sessions/templates/create_sessions.rb.erb +101 -0
  42. data/lib/generators/sessions/templates/create_sessions_events.rb.erb +108 -0
  43. data/lib/generators/sessions/templates/initializer.rb +201 -0
  44. data/lib/generators/sessions/templates/madmin/event_resource.rb +77 -0
  45. data/lib/generators/sessions/templates/madmin/session_events_controller.rb +21 -0
  46. data/lib/generators/sessions/templates/madmin/session_resource.rb +65 -0
  47. data/lib/generators/sessions/templates/madmin/sessions_controller.rb +30 -0
  48. data/lib/generators/sessions/templates/session.rb.erb +14 -0
  49. data/lib/generators/sessions/templates/sessions_sweep_job.rb +20 -0
  50. data/lib/generators/sessions/views_generator.rb +33 -0
  51. data/lib/sessions/adapters/omakase.rb +195 -0
  52. data/lib/sessions/adapters/omniauth.rb +64 -0
  53. data/lib/sessions/adapters/warden.rb +293 -0
  54. data/lib/sessions/classifier.rb +208 -0
  55. data/lib/sessions/configuration.rb +441 -0
  56. data/lib/sessions/current.rb +20 -0
  57. data/lib/sessions/device.rb +411 -0
  58. data/lib/sessions/engine.rb +120 -0
  59. data/lib/sessions/errors.rb +24 -0
  60. data/lib/sessions/geolocation.rb +111 -0
  61. data/lib/sessions/ip_address.rb +56 -0
  62. data/lib/sessions/jobs/geolocate_job.rb +58 -0
  63. data/lib/sessions/macros.rb +26 -0
  64. data/lib/sessions/middleware.rb +41 -0
  65. data/lib/sessions/models/concerns/device_display.rb +134 -0
  66. data/lib/sessions/models/concerns/has_sessions.rb +116 -0
  67. data/lib/sessions/models/concerns/model.rb +513 -0
  68. data/lib/sessions/models/event.rb +293 -0
  69. data/lib/sessions/version.rb +5 -0
  70. data/lib/sessions.rb +423 -0
  71. metadata +225 -0
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sessions
4
+ # Base controller for the devices page. It inherits from the HOST's
5
+ # controller (config.parent_controller, "::ApplicationController" by
6
+ # default) so the host's layout, helpers, auth filters, locale switching
7
+ # and exception handling all apply for free — the same integration style
8
+ # as Devise, api_keys and chats.
9
+ #
10
+ # Authentication is a CHAIN, so both stacks work with zero configuration:
11
+ #
12
+ # - Devise hosts: `config.authenticate_method` (:authenticate_user! by
13
+ # default) exists and runs.
14
+ # - Rails 8 omakase hosts: the inherited Authentication concern already
15
+ # registered `before_action :require_authentication` on the parent —
16
+ # parent callbacks run before ours, so anonymous visitors were
17
+ # redirected before this controller does anything.
18
+ # - Anything else: the final guard 404s rather than leaking the page.
19
+ #
20
+ # NOTE: the superclass is resolved when this class is autoloaded, which in
21
+ # a booted app happens AFTER initializers — so `config.parent_controller`
22
+ # set in config/initializers/sessions.rb is honored, and in development
23
+ # the class reloads pick up config changes too.
24
+ class ApplicationController < Sessions.config.parent_controller.constantize
25
+ # The omakase Authentication concern's `request_authentication` redirects
26
+ # with `new_session_path` — a HOST route helper, which named-helper
27
+ # dispatch resolves against the ENGINE's routes from in here (helpers
28
+ # call the receiver's url_for → the engine's _routes) and explodes. We
29
+ # skip the inherited filter and re-enforce the exact same behavior
30
+ # engine-safely in sessions_authenticate! below (raise: false makes this
31
+ # a no-op on Devise and custom stacks that don't have the filter).
32
+ skip_before_action :require_authentication, raise: false
33
+
34
+ before_action :sessions_authenticate!
35
+
36
+ helper Sessions::EngineHelper
37
+ helper_method :sessions_current_user, :sessions_current_session
38
+
39
+ # nil falls through to the parent controller's regular layout
40
+ # resolution, so by default the page looks like the rest of the host.
41
+ layout :sessions_layout
42
+
43
+ private
44
+
45
+ # The host's auth filters run INSIDE this engine controller (that's the
46
+ # whole point of the parent_controller inheritance) — and they reference
47
+ # the host's OWN route helpers (`new_session_path` in the omakase
48
+ # concern, custom redirects in hand-rolled filters), which an isolated
49
+ # engine can't resolve. Delegate unknown URL helpers to main_app so the
50
+ # host's code works here unmodified — the standard engine idiom.
51
+ def method_missing(method, *args, &block)
52
+ if method.to_s.end_with?("_path", "_url") && main_app.respond_to?(method)
53
+ main_app.public_send(method, *args, &block)
54
+ else
55
+ super
56
+ end
57
+ end
58
+
59
+ def respond_to_missing?(method, include_private = false)
60
+ (method.to_s.end_with?("_path", "_url") && main_app.respond_to?(method)) || super
61
+ end
62
+
63
+ # The signed-in user, via the resolver chain: configured method →
64
+ # :current_user → ::Current.session&.user (the omakase shape).
65
+ def sessions_current_user
66
+ @sessions_current_user ||= sessions_resolve_user
67
+ end
68
+
69
+ # The registry row serving THIS request — the row the page badges as
70
+ # "this device" and refuses to revoke.
71
+ def sessions_current_session
72
+ @sessions_current_session ||= Sessions.current(request)
73
+ end
74
+
75
+ def sessions_resolve_user
76
+ configured = Sessions.config.current_user_method
77
+ return send(configured) if configured && respond_to?(configured, true)
78
+ return current_user if respond_to?(:current_user, true)
79
+
80
+ ::Current.try(:session)&.user if defined?(::Current)
81
+ end
82
+
83
+ def sessions_authenticate!
84
+ method = Sessions.config.authenticate_method
85
+ if method && respond_to?(method, true)
86
+ # Devise hosts (and anything that defines the configured filter).
87
+ send(method)
88
+ return if performed?
89
+ elsif respond_to?(:resume_session, true)
90
+ # Omakase hosts: the same require_authentication contract as the
91
+ # generated concern, with the login redirect generated through
92
+ # main_app (engine-safe).
93
+ unless resume_session
94
+ session[:return_to_after_authenticating] = request.url
95
+ return redirect_to main_app.new_session_path if main_app.respond_to?(:new_session_path)
96
+
97
+ head :not_found
98
+ return
99
+ end
100
+ end
101
+
102
+ # Privacy default: a host where no user resolves gets a plain 404 —
103
+ # the page's existence never leaks to the unauthenticated.
104
+ head :not_found unless sessions_current_user
105
+ end
106
+
107
+ def sessions_layout
108
+ Sessions.config.layout
109
+ end
110
+
111
+ # The optional sudo gate (ASVS 3.3.4's "having re-entered login
112
+ # credentials"). The host's proc receives the controller; the action
113
+ # proceeds ONLY when the gate returns truthy without rendering:
114
+ #
115
+ # - render/redirect inside the gate → blocked (your confirm flow owns
116
+ # the response)
117
+ # - return false/nil → blocked; if the gate rendered nothing, we
118
+ # answer 403 so a falsy gate can never fall through to the
119
+ # destructive action (a sudo gate fails CLOSED)
120
+ # - return truthy → allowed
121
+ def sessions_reauthenticate!
122
+ gate = Sessions.config.require_reauthentication
123
+ return true unless gate
124
+
125
+ allowed = gate.call(self)
126
+ return false if performed?
127
+
128
+ head :forbidden unless allowed
129
+ !!allowed
130
+ end
131
+
132
+ # Every query goes through the OWNER's sessions — you can never touch a
133
+ # row you don't own, even if the host's mount is misconfigured.
134
+ def sessions_owner_sessions
135
+ user = sessions_current_user
136
+ if user.respond_to?(:sessions)
137
+ user.sessions
138
+ else
139
+ Sessions.session_model.where(user: user)
140
+ end
141
+ end
142
+
143
+ # The user's slice of the trail INCLUDING failed attempts against their
144
+ # own identity (failures never link to an account — enumeration safety —
145
+ # but showing a signed-in user the attempts typed against their own
146
+ # email is exactly what a security page is for).
147
+ def sessions_owner_events
148
+ user = sessions_current_user
149
+ return user.session_history if user.respond_to?(:session_history)
150
+
151
+ # Hosts without has_sessions on the resolved user get the same slice
152
+ # inline: owned events plus identity-matched failures.
153
+ scope = Sessions::Event.where(authenticatable: user)
154
+ identity = Sessions::Event.normalize_identity(user.try(:email_address) || user.try(:email))
155
+ scope = scope.or(Sessions::Event.where(identity: identity)) if identity
156
+ scope
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sessions
4
+ # The "Your devices" page: list, revoke one, sign out everywhere else,
5
+ # and the login history. Deliberately trivial — if you need custom
6
+ # controller behavior, render the partials from your own controller
7
+ # instead (the README's Layer 1).
8
+ class DevicesController < ApplicationController
9
+ # Current session always first (and never revocable from this page —
10
+ # signing out the device you're on is the app's normal logout).
11
+ def index
12
+ @sessions = sessions_owner_sessions.by_recency.to_a
13
+ if (current = @sessions.find { |session| session == sessions_current_session })
14
+ @sessions.delete(current)
15
+ @sessions.unshift(current)
16
+ end
17
+ @events = sessions_owner_events.recent.limit(10)
18
+ end
19
+
20
+ def history
21
+ @events = sessions_owner_events.recent.limit(200)
22
+ end
23
+
24
+ def destroy
25
+ return unless sessions_reauthenticate!
26
+
27
+ session_row = sessions_owner_sessions.find(params[:id])
28
+
29
+ if session_row.current?(request)
30
+ redirect_to devices_path, alert: t("sessions.devices.cannot_revoke_current")
31
+ return
32
+ end
33
+
34
+ session_row.revoke!(reason: :user_revoked, by: sessions_current_user)
35
+ redirect_to devices_path, notice: t("sessions.devices.revoked"), status: :see_other
36
+ end
37
+
38
+ def others
39
+ return unless sessions_reauthenticate!
40
+
41
+ sessions_current_user.revoke_other_sessions!(
42
+ current: sessions_current_session,
43
+ by: sessions_current_user
44
+ )
45
+ redirect_to devices_path, notice: t("sessions.devices.revoked_others"), status: :see_other
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sessions
4
+ # View helpers for the devices page (and for the host-renderable
5
+ # partials).
6
+ module EngineHelper
7
+ DEVICE_ICONS = {
8
+ "desktop" => "🖥",
9
+ "smartphone" => "📱",
10
+ "tablet" => "📱",
11
+ "native_ios" => "📱",
12
+ "native_android" => "📱",
13
+ "bot" => "🤖"
14
+ }.freeze
15
+
16
+ # Semantic icon names (Heroicons vocabulary — map them onto whatever
17
+ # icon helper your app uses) so custom views don't re-derive the
18
+ # device_type → icon mapping the gem already knows.
19
+ DEVICE_ICON_NAMES = {
20
+ "desktop" => "computer-desktop",
21
+ "smartphone" => "device-phone-mobile",
22
+ "tablet" => "device-tablet",
23
+ "native_ios" => "device-phone-mobile",
24
+ "native_android" => "device-phone-mobile",
25
+ "bot" => "bug-ant"
26
+ }.freeze
27
+
28
+ EVENT_ICON_NAMES = {
29
+ "login" => "check-circle",
30
+ "failed_login" => "x-circle",
31
+ "logout" => "arrow-right-on-rectangle",
32
+ "revoked" => "no-symbol",
33
+ "expired" => "clock"
34
+ }.freeze
35
+
36
+ def sessions_device_icon(session)
37
+ DEVICE_ICONS.fetch(session.device_type.to_s, "🌐")
38
+ end
39
+
40
+ def sessions_device_icon_name(session)
41
+ DEVICE_ICON_NAMES.fetch(session.device_type.to_s, "globe-alt")
42
+ end
43
+
44
+ def sessions_event_icon_name(event)
45
+ EVENT_ICON_NAMES.fetch(event.event.to_s, "information-circle")
46
+ end
47
+
48
+ # "Active now" within the touch window, else "Active 3 minutes ago".
49
+ def sessions_last_active_in_words(session)
50
+ # active_now? owns the window (config.touch_every) — the badge can't
51
+ # honestly claim more freshness than the throttle records.
52
+ return t("sessions.devices.active_now") if session.respond_to?(:active_now?) && session.active_now?
53
+
54
+ time = session.last_active_at
55
+ return nil unless time
56
+
57
+ t("sessions.devices.active_ago", time: time_ago_in_words(time))
58
+ end
59
+
60
+ # The engine's route proxy when it's mounted, nil otherwise — partials
61
+ # rendered inside a host that didn't mount the engine simply omit the
62
+ # revoke buttons. The proxy method is named after the mount (`sessions`
63
+ # by default, or whatever `as:` was given), so it's DISCOVERED from the
64
+ # host's named routes rather than assumed.
65
+ def sessions_engine_routes
66
+ name = Sessions::EngineHelper.engine_mount_name
67
+ name && respond_to?(name) ? public_send(name) : nil
68
+ end
69
+
70
+ # The name of the route that mounts Sessions::Engine in the host app
71
+ # ("sessions" for a plain mount, the `as:` value otherwise). Memoized
72
+ # per-process; in development a changed mount name reloads routes and
73
+ # to_prepare re-touches this helper file, clearing the memo.
74
+ def self.engine_mount_name
75
+ return @engine_mount_name if defined?(@engine_mount_name)
76
+
77
+ @engine_mount_name = begin
78
+ route = Rails.application.routes.routes.find do |candidate|
79
+ # The mount sits behind a Constraints wrapper; unwrap a bounded
80
+ # number of times, checking BEFORE each unwrap — Rails::Engine
81
+ # itself responds to #app (its middleware stack), so an unguarded
82
+ # `while app.respond_to?(:app)` would walk straight past it.
83
+ app = candidate.app
84
+ matched = false
85
+ 3.times do
86
+ matched = (app == Sessions::Engine)
87
+ break if matched || !app.respond_to?(:app)
88
+
89
+ app = app.app
90
+ end
91
+ matched
92
+ end
93
+ route&.name
94
+ rescue StandardError
95
+ nil
96
+ end
97
+ end
98
+
99
+ def self.reset_engine_mount_name!
100
+ remove_instance_variable(:@engine_mount_name) if defined?(@engine_mount_name)
101
+ end
102
+
103
+ # "Signed in May 2, 2026" — localized when the host bundles date
104
+ # formats (rails-i18n or its own locale files), with a safe fallback so
105
+ # a bare host never 500s over a missing `date.formats.long`.
106
+ def sessions_format_date(date)
107
+ I18n.l(date, format: :long)
108
+ rescue I18n::MissingTranslationData, I18n::ArgumentError
109
+ date.strftime("%Y-%m-%d")
110
+ end
111
+
112
+ def sessions_format_time(time)
113
+ I18n.l(time, format: :short)
114
+ rescue I18n::MissingTranslationData, I18n::ArgumentError
115
+ time.strftime("%Y-%m-%d %H:%M")
116
+ end
117
+ end
118
+ end
119
+
120
+ # Expose the helpers to the HOST's views too, so the Layer-1 partials
121
+ # (`render "sessions/devices"`) work outside the engine. Registered at the
122
+ # bottom of this file (not from an engine initializer) so the hook is
123
+ # self-resolving on hosts where ActionView loads early — the engine's
124
+ # to_prepare touches this constant to guarantee the file loads every boot
125
+ # (the moderate/chats-proven pattern).
126
+ ActiveSupport.on_load(:action_view) { include Sessions::EngineHelper }
@@ -0,0 +1,40 @@
1
+ <%# One device row. Locals: session, current_session, routes (engine proxy or nil). %>
2
+ <% current = session == current_session %>
3
+ <li class="sessions-device <%= "sessions-device-current" if current %>">
4
+ <span class="sessions-device-icon"><%= sessions_device_icon(session) %></span>
5
+
6
+ <div class="sessions-device-info">
7
+ <p class="sessions-device-name">
8
+ <%= session.device_name %>
9
+ <%# Auth method as a subtle pill — only when it says something a user
10
+ cares about (their OAuth provider, a passkey…). "password" is the
11
+ default and stays silent. %>
12
+ <% if session.auth_method_label && !session.via_password? %>
13
+ <span class="sessions-method-pill"><%= session.auth_method_label %></span>
14
+ <% end %>
15
+ </p>
16
+ <p class="sessions-device-meta">
17
+ <% if session.location %>
18
+ <span class="sessions-device-location" title="<%= t("sessions.devices.location_approximate") %>">
19
+ <%= [session.country_flag, session.location].compact.join(" ") %>
20
+ </span> ·
21
+ <% end %>
22
+ <span class="sessions-device-seen"><%= sessions_last_active_in_words(session) %></span>
23
+ <% if session.created_at %>
24
+ · <span class="sessions-device-signed-in"><%= t("sessions.devices.signed_in", date: sessions_format_date(session.created_at.to_date)) %></span>
25
+ <% end %>
26
+ </p>
27
+ </div>
28
+
29
+ <%# The action slot: the current session's badge sits where other rows
30
+ show their Log out button — one consistent right-aligned column. %>
31
+ <% if current %>
32
+ <span class="sessions-badge"><%= t("sessions.devices.this_device") %></span>
33
+ <% elsif routes %>
34
+ <%= button_to t("sessions.devices.log_out"),
35
+ routes.device_path(session),
36
+ method: :delete,
37
+ class: "sessions-button sessions-button-revoke",
38
+ form: { data: { turbo_confirm: t("sessions.devices.log_out_confirm") } } %>
39
+ <% end %>
40
+ </li>
@@ -0,0 +1,34 @@
1
+ <%# The live device registry — renderable from the engine OR straight from %>
2
+ <%# any host view: render "sessions/devices", user: current_user %>
3
+ <%# Locals: %>
4
+ <%# sessions: the rows to render (defaults to user.sessions) %>
5
+ <%# user: whose devices (only needed when sessions: isn't given)%>
6
+ <%# current_session: the row serving this request (badged, not revocable) %>
7
+ <%
8
+ current_session = local_assigns[:current_session] || Sessions.current(request)
9
+ sessions = local_assigns[:sessions] ||
10
+ local_assigns[:user]&.sessions&.by_recency&.to_a ||
11
+ []
12
+ routes = sessions_engine_routes if respond_to?(:sessions_engine_routes)
13
+ %>
14
+ <section class="sessions-devices">
15
+ <% if sessions.empty? %>
16
+ <p class="sessions-empty"><%= t("sessions.devices.empty") %></p>
17
+ <% else %>
18
+ <ul class="sessions-device-list">
19
+ <% sessions.each do |session| %>
20
+ <%= render "sessions/device", session: session, current_session: current_session, routes: routes %>
21
+ <% end %>
22
+ </ul>
23
+
24
+ <% if routes && sessions.many? %>
25
+ <div class="sessions-revoke-others">
26
+ <%= button_to t("sessions.devices.sign_out_others"),
27
+ routes.others_devices_path,
28
+ method: :delete,
29
+ class: "sessions-button sessions-button-secondary",
30
+ form: { data: { turbo_confirm: t("sessions.devices.sign_out_others_confirm") } } %>
31
+ </div>
32
+ <% end %>
33
+ <% end %>
34
+ </section>
@@ -0,0 +1,13 @@
1
+ <%# One trail row. Local: event. %>
2
+ <li class="sessions-event sessions-event-<%= event.event %>">
3
+ <span class="sessions-event-icon">
4
+ <%= { "login" => "✓", "failed_login" => "✗", "logout" => "→", "revoked" => "⊘", "expired" => "⏱" }.fetch(event.event, "·") %>
5
+ </span>
6
+ <span class="sessions-event-label">
7
+ <%= t("sessions.history.events.#{event.event}", default: event.event.humanize) %><% if event.failure? && event.failure_reason.present? %> <span class="sessions-event-reason">(<%= t("sessions.history.reasons.#{event.failure_reason}", default: event.failure_reason.humanize.downcase) %>)</span><% end %><% if event.event == "revoked" && event.revoked_reason.present? %> <span class="sessions-event-reason">(<%= t("sessions.history.reasons.#{event.revoked_reason}", default: event.revoked_reason.humanize.downcase) %>)</span><% end %>
8
+ </span>
9
+ <% device = [event.browser_name, event.app_name].compact.first %>
10
+ <% if device %> · <span class="sessions-event-device"><%= device %><%= " #{t("sessions.history.on")} #{event.os_name}" if event.os_name %></span><% end %>
11
+ <% if event.location %> · <span class="sessions-event-location"><%= event.location %></span><% end %>
12
+ <% if event.occurred_at %> · <time class="sessions-event-time" datetime="<%= event.occurred_at.iso8601 %>"><%= sessions_format_time(event.occurred_at) %></time><% end %>
13
+ </li>
@@ -0,0 +1,20 @@
1
+ <%# The login-activity trail — renderable from any host view too: %>
2
+ <%# render "sessions/history", user: current_user, limit: 10 %>
3
+ <%# Locals: %>
4
+ <%# events: the trail rows (defaults to user.session_events.recent) %>
5
+ <%# user: whose history (only needed when events: isn't given) %>
6
+ <%# limit: cap when deriving from user (default 10) %>
7
+ <%
8
+ events = local_assigns[:events] ||
9
+ local_assigns[:user]&.session_events&.recent&.limit(local_assigns.fetch(:limit, 10)) ||
10
+ []
11
+ %>
12
+ <% if events.respond_to?(:empty?) && events.empty? %>
13
+ <p class="sessions-empty"><%= t("sessions.history.empty") %></p>
14
+ <% else %>
15
+ <ul class="sessions-event-list">
16
+ <% events.each do |event| %>
17
+ <%= render "sessions/event", event: event %>
18
+ <% end %>
19
+ </ul>
20
+ <% end %>
@@ -0,0 +1,5 @@
1
+ <div class="sessions-page">
2
+ <h1 class="sessions-title"><%= t("sessions.history.title") %></h1>
3
+ <%= render "sessions/history", events: @events %>
4
+ <p><%= link_to t("sessions.history.back_to_devices"), devices_path, class: "sessions-history-link" %></p>
5
+ </div>
@@ -0,0 +1,15 @@
1
+ <%# The "Your devices" page — the GitHub/Google contract: device label, %>
2
+ <%# approximate location, last active, per-row revoke, sign out everywhere. %>
3
+ <div class="sessions-page">
4
+ <h1 class="sessions-title"><%= t("sessions.devices.title") %></h1>
5
+
6
+ <%= render "sessions/devices", sessions: @sessions, current_session: sessions_current_session %>
7
+
8
+ <section class="sessions-history">
9
+ <header class="sessions-history-header">
10
+ <h2 class="sessions-subtitle"><%= t("sessions.history.title") %></h2>
11
+ <%= link_to t("sessions.history.see_all"), history_devices_path, class: "sessions-history-link" %>
12
+ </header>
13
+ <%= render "sessions/history", events: @events %>
14
+ </section>
15
+ </div>
@@ -0,0 +1,59 @@
1
+ en:
2
+ sessions:
3
+ devices:
4
+ title: "Your devices"
5
+ empty: "No active sessions."
6
+ this_device: "This device"
7
+ log_out: "Log out"
8
+ log_out_confirm: "Log this device out?"
9
+ sign_out_others: "Sign out of all sessions except this one"
10
+ sign_out_others_confirm: "You'll stay signed in here, and signed out everywhere else."
11
+ cannot_revoke_current: "You can't log out the device you're using from here — use your regular sign out."
12
+ revoked: "Device logged out."
13
+ revoked_others: "Signed out of all other sessions."
14
+ location_approximate: "Approximate location, based on IP address"
15
+ active_now: "Active now"
16
+ active_ago: "Active %{time} ago"
17
+ signed_in: "Signed in %{date}"
18
+ via: "via %{method}"
19
+ history:
20
+ title: "Login history"
21
+ see_all: "See all"
22
+ back_to_devices: "Back to your devices"
23
+ empty: "No login activity yet."
24
+ # Quoted: a bare `on:` is a BOOLEAN key in YAML 1.1.
25
+ "on": "on"
26
+ events:
27
+ login: "Signed in"
28
+ failed_login: "Failed sign-in attempt"
29
+ logout: "Signed out"
30
+ revoked: "Session revoked"
31
+ expired: "Session expired"
32
+ reasons:
33
+ invalid: "wrong credentials"
34
+ invalid_credentials: "wrong credentials"
35
+ not_found_in_database: "wrong credentials"
36
+ rate_limited: "too many attempts"
37
+ access_denied: "cancelled at provider"
38
+ authenticity_error: "security check failed"
39
+ user_revoked: "you logged it out"
40
+ admin_revoked: "revoked by an administrator"
41
+ password_change: "password was changed"
42
+ logout_everywhere: "you signed out everywhere"
43
+ pruned: "device limit reached"
44
+ superseded: "replaced by a newer sign-in on this device"
45
+ expired: "expired"
46
+ unknown: "session ended"
47
+ auth_methods:
48
+ password: "password"
49
+ oauth: "OAuth"
50
+ google_one_tap: "Google One Tap"
51
+ passkey: "passkey"
52
+ magic_link: "magic link"
53
+ otp: "one-time code"
54
+ sso: "SSO"
55
+ token: "token"
56
+ device_name:
57
+ composite: "%{client} on %{platform}"
58
+ unknown: "Unknown device"
59
+ bot: "Bot (%{name})"
@@ -0,0 +1,59 @@
1
+ es:
2
+ sessions:
3
+ devices:
4
+ title: "Tus dispositivos"
5
+ empty: "No hay sesiones activas."
6
+ this_device: "Este dispositivo"
7
+ log_out: "Cerrar sesión"
8
+ log_out_confirm: "¿Cerrar la sesión de este dispositivo?"
9
+ sign_out_others: "Cerrar todas las sesiones menos esta"
10
+ sign_out_others_confirm: "Seguirás conectado aquí, y se cerrará la sesión en todos los demás dispositivos."
11
+ cannot_revoke_current: "No puedes cerrar desde aquí la sesión del dispositivo que estás usando — usa el cierre de sesión normal."
12
+ revoked: "Sesión cerrada en ese dispositivo."
13
+ revoked_others: "Sesión cerrada en todos los demás dispositivos."
14
+ location_approximate: "Ubicación aproximada, según la dirección IP"
15
+ active_now: "Activo ahora"
16
+ active_ago: "Activo hace %{time}"
17
+ signed_in: "Sesión iniciada el %{date}"
18
+ via: "con %{method}"
19
+ history:
20
+ title: "Historial de accesos"
21
+ see_all: "Ver todo"
22
+ back_to_devices: "Volver a tus dispositivos"
23
+ empty: "Aún no hay actividad de acceso."
24
+ # Entrecomillado: un `on:` a secas es una clave BOOLEANA en YAML 1.1.
25
+ "on": "en"
26
+ events:
27
+ login: "Inicio de sesión"
28
+ failed_login: "Intento de acceso fallido"
29
+ logout: "Cierre de sesión"
30
+ revoked: "Sesión revocada"
31
+ expired: "Sesión caducada"
32
+ reasons:
33
+ invalid: "credenciales incorrectas"
34
+ invalid_credentials: "credenciales incorrectas"
35
+ not_found_in_database: "credenciales incorrectas"
36
+ rate_limited: "demasiados intentos"
37
+ access_denied: "cancelado en el proveedor"
38
+ authenticity_error: "falló la comprobación de seguridad"
39
+ user_revoked: "la cerraste tú"
40
+ admin_revoked: "revocada por un administrador"
41
+ password_change: "se cambió la contraseña"
42
+ logout_everywhere: "cerraste sesión en todas partes"
43
+ pruned: "límite de dispositivos alcanzado"
44
+ superseded: "sustituida por un nuevo inicio de sesión en este dispositivo"
45
+ expired: "caducada"
46
+ unknown: "sesión finalizada"
47
+ auth_methods:
48
+ password: "contraseña"
49
+ oauth: "OAuth"
50
+ google_one_tap: "Google One Tap"
51
+ passkey: "passkey"
52
+ magic_link: "enlace mágico"
53
+ otp: "código de un solo uso"
54
+ sso: "SSO"
55
+ token: "token"
56
+ device_name:
57
+ composite: "%{client} en %{platform}"
58
+ unknown: "Dispositivo desconocido"
59
+ bot: "Bot (%{name})"
data/config/routes.rb ADDED
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ Sessions::Engine.routes.draw do
4
+ # `path: ""` keeps URLs short under the host's mount point: with
5
+ # `mount Sessions::Engine => "/settings/sessions"` the devices page is
6
+ # /settings/sessions, revoking one is DELETE /settings/sessions/:id,
7
+ # "sign out everywhere else" is DELETE /settings/sessions/others, and the
8
+ # full login history is /settings/sessions/history.
9
+ resources :devices, path: "", only: %i[index destroy] do
10
+ collection do
11
+ delete :others
12
+ get :history
13
+ end
14
+ end
15
+
16
+ root to: "devices#index"
17
+ end