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.
- checksums.yaml +7 -0
- data/.rubocop.yml +61 -0
- data/.simplecov +54 -0
- data/AGENTS.md +5 -0
- data/Appraisals +26 -0
- data/CHANGELOG.md +26 -0
- data/CLAUDE.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +454 -0
- data/Rakefile +32 -0
- data/app/assets/stylesheets/sessions.css +50 -0
- data/app/controllers/sessions/application_controller.rb +159 -0
- data/app/controllers/sessions/devices_controller.rb +48 -0
- data/app/helpers/sessions/engine_helper.rb +126 -0
- data/app/views/sessions/_device.html.erb +40 -0
- data/app/views/sessions/_devices.html.erb +34 -0
- data/app/views/sessions/_event.html.erb +13 -0
- data/app/views/sessions/_history.html.erb +20 -0
- data/app/views/sessions/devices/history.html.erb +5 -0
- data/app/views/sessions/devices/index.html.erb +15 -0
- data/config/locales/en.yml +59 -0
- data/config/locales/es.yml +59 -0
- data/config/routes.rb +17 -0
- data/docs/PRD.md +743 -0
- data/docs/research/01-carhey.md +250 -0
- data/docs/research/02-ecosystem.md +261 -0
- data/docs/research/03-rails-core.md +220 -0
- data/docs/research/04-devise-warden.md +249 -0
- data/docs/research/05-oauth.md +193 -0
- data/docs/research/06-prior-art.md +312 -0
- data/docs/research/07-device-detection.md +250 -0
- data/docs/research/08-rails8-landscape.md +216 -0
- data/docs/research/09-market-security.md +450 -0
- data/gemfiles/rails_7.1.gemfile +34 -0
- data/gemfiles/rails_7.2.gemfile +34 -0
- data/gemfiles/rails_8.0.gemfile +34 -0
- data/gemfiles/rails_8.1.gemfile +34 -0
- data/lib/generators/sessions/install_generator.rb +230 -0
- data/lib/generators/sessions/madmin_generator.rb +95 -0
- data/lib/generators/sessions/templates/add_sessions_columns.rb.erb +81 -0
- data/lib/generators/sessions/templates/create_sessions.rb.erb +101 -0
- data/lib/generators/sessions/templates/create_sessions_events.rb.erb +108 -0
- data/lib/generators/sessions/templates/initializer.rb +201 -0
- data/lib/generators/sessions/templates/madmin/event_resource.rb +77 -0
- data/lib/generators/sessions/templates/madmin/session_events_controller.rb +21 -0
- data/lib/generators/sessions/templates/madmin/session_resource.rb +65 -0
- data/lib/generators/sessions/templates/madmin/sessions_controller.rb +30 -0
- data/lib/generators/sessions/templates/session.rb.erb +14 -0
- data/lib/generators/sessions/templates/sessions_sweep_job.rb +20 -0
- data/lib/generators/sessions/views_generator.rb +33 -0
- data/lib/sessions/adapters/omakase.rb +195 -0
- data/lib/sessions/adapters/omniauth.rb +64 -0
- data/lib/sessions/adapters/warden.rb +293 -0
- data/lib/sessions/classifier.rb +208 -0
- data/lib/sessions/configuration.rb +441 -0
- data/lib/sessions/current.rb +20 -0
- data/lib/sessions/device.rb +411 -0
- data/lib/sessions/engine.rb +120 -0
- data/lib/sessions/errors.rb +24 -0
- data/lib/sessions/geolocation.rb +111 -0
- data/lib/sessions/ip_address.rb +56 -0
- data/lib/sessions/jobs/geolocate_job.rb +58 -0
- data/lib/sessions/macros.rb +26 -0
- data/lib/sessions/middleware.rb +41 -0
- data/lib/sessions/models/concerns/device_display.rb +134 -0
- data/lib/sessions/models/concerns/has_sessions.rb +116 -0
- data/lib/sessions/models/concerns/model.rb +513 -0
- data/lib/sessions/models/event.rb +293 -0
- data/lib/sessions/version.rb +5 -0
- data/lib/sessions.rb +423 -0
- 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,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
|