diff --git a/app/controllers/api/v1/profile/sessions_controller.rb b/app/controllers/api/v1/profile/sessions_controller.rb
new file mode 100644
index 000000000..a707a297c
--- /dev/null
+++ b/app/controllers/api/v1/profile/sessions_controller.rb
@@ -0,0 +1,31 @@
+class Api::V1::Profile::SessionsController < Api::BaseController
+ before_action :set_session, only: [:destroy]
+
+ def index
+ @sessions = current_user.user_sessions.order(last_activity_at: :desc)
+ @current_client_id = request.headers['client']
+ end
+
+ def destroy
+ if @session.current?(request.headers['client'])
+ render json: { error: I18n.t('profile_settings.sessions.cannot_revoke_current') }, status: :unprocessable_entity
+ return
+ end
+
+ revoke_token!(@session.client_id)
+ @session.destroy!
+ head :ok
+ end
+
+ private
+
+ def set_session
+ @session = current_user.user_sessions.find(params[:id])
+ end
+
+ def revoke_token!(client_id)
+ tokens = current_user.tokens
+ tokens.delete(client_id)
+ current_user.update!(tokens: tokens)
+ end
+end
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 2f389049d..9dea4b4da 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -3,6 +3,7 @@ class ApplicationController < ActionController::Base
include RequestExceptionHandler
include Pundit::Authorization
include SwitchLocale
+ include TrackSessionActivity
skip_before_action :verify_authenticity_token
diff --git a/app/controllers/concerns/track_session_activity.rb b/app/controllers/concerns/track_session_activity.rb
new file mode 100644
index 000000000..f6a512922
--- /dev/null
+++ b/app/controllers/concerns/track_session_activity.rb
@@ -0,0 +1,22 @@
+module TrackSessionActivity
+ extend ActiveSupport::Concern
+
+ included do
+ after_action :update_session_activity
+ end
+
+ private
+
+ def update_session_activity
+ return unless current_user
+ return if request.headers['client'].blank?
+
+ UserSessionTrackingService.new(
+ user: current_user,
+ request: request,
+ client_id: request.headers['client']
+ ).update_activity!
+ rescue StandardError => e
+ Rails.logger.warn "Session activity update failed: #{e.message}"
+ end
+end
diff --git a/app/controllers/devise_overrides/sessions_controller.rb b/app/controllers/devise_overrides/sessions_controller.rb
index bd7bb9b44..bb93c060c 100644
--- a/app/controllers/devise_overrides/sessions_controller.rb
+++ b/app/controllers/devise_overrides/sessions_controller.rb
@@ -1,4 +1,6 @@
class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
+ MAX_SESSIONS = 5
+
# Prevent session parameter from being passed
# Unpermitted parameter: session
wrap_parameters format: []
@@ -14,12 +16,14 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
user = find_user_for_authentication
return handle_mfa_required(user) if user&.mfa_enabled?
+ return if user && enforce_session_limit_for_password_login(user)
# Only proceed with standard authentication if no MFA is required
super
end
def render_create_success
+ track_user_session
render partial: 'devise/auth', formats: [:json], locals: { resource: @resource }
end
@@ -53,6 +57,8 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
end
def handle_sso_authentication
+ return if enforce_session_limit_for_password_login(@resource)
+
authenticate_resource_with_sso_token
yield @resource if block_given?
render_create_success
@@ -103,6 +109,7 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
end
def sign_in_mfa_user(user)
+ evict_oldest_session(user) if sessions_limit_reached?(user)
@resource = user
@token = @resource.create_token
@resource.save!
@@ -114,6 +121,109 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
def render_mfa_error(message_key, status = :bad_request)
render json: { error: I18n.t(message_key) }, status: status
end
+
+ def sessions_limit_reached?(user)
+ (user.tokens || {}).keys.size >= MAX_SESSIONS
+ end
+
+ # Returns true when the response has been rendered (e.g., 409 picker shown). Browsers see
+ # the picker; non-browser clients (mobile, API) auto-evict so they don't get stuck on a UI
+ # they can't render. If the user is revoking, perform the revoke and let login proceed.
+ def enforce_session_limit_for_password_login(user)
+ if revoking_sessions?
+ revoke_sessions_for_login(user)
+ return false
+ end
+
+ return false unless sessions_limit_reached?(user)
+
+ if browser_request?
+ handle_sessions_limit_for_login(user)
+ true
+ else
+ evict_oldest_session(user)
+ false
+ end
+ end
+
+ def browser_request?
+ request.user_agent.to_s.include?('Mozilla')
+ end
+
+ def revoking_sessions?
+ params[:revoke_session_id].present? || params[:revoke_all_sessions].present?
+ end
+
+ def revoke_sessions_for_login(user)
+ if params[:revoke_all_sessions].present?
+ user.tokens = {}
+ user.save!
+ user.user_sessions.destroy_all
+ elsif params[:revoke_session_id].present?
+ session = user.user_sessions.find_by(id: params[:revoke_session_id])
+ return unless session
+
+ user.tokens.delete(session.client_id)
+ user.save!
+ session.destroy!
+ end
+ end
+
+ def evict_oldest_session(user)
+ oldest_session = user.user_sessions.order(Arel.sql('COALESCE(last_activity_at, created_at) ASC')).first
+ return evict_oldest_token(user) unless oldest_session
+
+ user.tokens.delete(oldest_session.client_id)
+ user.save!
+ oldest_session.destroy!
+ end
+
+ # Fallback if a token exists without a UserSession row (e.g., legacy data before tracking shipped).
+ def evict_oldest_token(user)
+ return if user.tokens.blank?
+
+ oldest_client_id = user.tokens.min_by { |_, v| v['expiry'].to_i }&.first
+ return unless oldest_client_id
+
+ user.tokens.delete(oldest_client_id)
+ user.save!
+ end
+
+ def handle_sessions_limit_for_login(user)
+ sessions = user.user_sessions.order(last_activity_at: :desc).map do |session|
+ {
+ id: session.id,
+ browser_name: session.browser_name,
+ browser_version: session.browser_version,
+ device_name: session.device_name,
+ platform_name: session.platform_name,
+ platform_version: session.platform_version,
+ ip_address: session.ip_address,
+ city: session.city,
+ country: session.country,
+ last_activity_at: session.last_activity_at,
+ created_at: session.created_at
+ }
+ end
+
+ render json: {
+ sessions_limit_reached: true,
+ sessions: sessions
+ }, status: :conflict
+ end
+
+ def track_user_session
+ client_id = @token&.try(:client) || response.headers['client']
+ return unless client_id.present? && @resource.present?
+
+ UserSessionTrackingService.new(
+ user: @resource,
+ request: request,
+ client_id: client_id
+ ).create_or_update!
+ rescue StandardError => e
+ Rails.logger.warn "Session tracking failed: #{e.message}"
+ end
end
DeviseOverrides::SessionsController.prepend_mod_with('DeviseOverrides::SessionsController')
diff --git a/app/javascript/dashboard/api/auth.js b/app/javascript/dashboard/api/auth.js
index a1b15ee79..b9dc59964 100644
--- a/app/javascript/dashboard/api/auth.js
+++ b/app/javascript/dashboard/api/auth.js
@@ -106,4 +106,10 @@ export default {
const urlData = endPoints('resetAccessToken');
return axios.post(urlData.url);
},
+ getSessions() {
+ return axios.get('/api/v1/profile/sessions');
+ },
+ revokeSession(id) {
+ return axios.delete(`/api/v1/profile/sessions/${id}`);
+ },
};
diff --git a/app/javascript/dashboard/components/auth/SessionLimitOverlay.vue b/app/javascript/dashboard/components/auth/SessionLimitOverlay.vue
new file mode 100644
index 000000000..98fdc3605
--- /dev/null
+++ b/app/javascript/dashboard/components/auth/SessionLimitOverlay.vue
@@ -0,0 +1,133 @@
+
+
+
+
+
+
+
+
+
+ {{ $t('SESSION_LIMIT.TITLE') }}
+
+
+ {{ $t('SESSION_LIMIT.DESCRIPTION') }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ sessionLabel(session) }}
+
+
+ {{
+ `${$t('SESSION_LIMIT.STARTED')} ${formatDate(session.created_at)}, ${formatTime(session.created_at)}`
+ }}
+
+
+
+
+
+
+
+
+
+ emit('cancel')"
+ />
+
+
+
+
diff --git a/app/javascript/dashboard/i18n/locale/en/index.js b/app/javascript/dashboard/i18n/locale/en/index.js
index 31486a247..12db16ba7 100644
--- a/app/javascript/dashboard/i18n/locale/en/index.js
+++ b/app/javascript/dashboard/i18n/locale/en/index.js
@@ -40,6 +40,7 @@ import whatsappTemplates from './whatsappTemplates.json';
import contentTemplates from './contentTemplates.json';
import mfa from './mfa.json';
import onboarding from './onboarding.json';
+import sessionLimit from './sessionLimit.json';
import yearInReview from './yearInReview.json';
export default {
@@ -85,5 +86,6 @@ export default {
...contentTemplates,
...mfa,
...onboarding,
+ ...sessionLimit,
...yearInReview,
};
diff --git a/app/javascript/dashboard/i18n/locale/en/sessionLimit.json b/app/javascript/dashboard/i18n/locale/en/sessionLimit.json
new file mode 100644
index 000000000..926745c23
--- /dev/null
+++ b/app/javascript/dashboard/i18n/locale/en/sessionLimit.json
@@ -0,0 +1,12 @@
+{
+ "SESSION_LIMIT": {
+ "TITLE": "Active session limit reached",
+ "DESCRIPTION": "You have reached your limit of active sessions. Please end a session before logging in.",
+ "END": "End",
+ "END_ALL": "End all sessions",
+ "LOG_IN": "Log in",
+ "CANCEL": "Back to login",
+ "UNKNOWN_DEVICE": "Unknown device",
+ "STARTED": "Started"
+ }
+}
diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json
index bfbd920a7..08a4f1133 100644
--- a/app/javascript/dashboard/i18n/locale/en/settings.json
+++ b/app/javascript/dashboard/i18n/locale/en/settings.json
@@ -86,6 +86,16 @@
"NOTE": "Manage additional security features for your account.",
"MFA_BUTTON": "Manage Two-Factor Authentication"
},
+ "SESSIONS_SECTION": {
+ "TITLE": "Active Sessions",
+ "NOTE": "These are the devices currently logged in to your account.",
+ "CURRENT": "Current session",
+ "REVOKE": "Revoke",
+ "REVOKE_SUCCESS": "Session revoked successfully",
+ "REVOKE_ERROR": "Unable to revoke session. Please try again.",
+ "FETCH_ERROR": "Unable to fetch sessions. Please try again.",
+ "LAST_ACTIVE": "Last active"
+ },
"ACCESS_TOKEN": {
"TITLE": "Access Token",
"NOTE": "This token can be used if you are building an API based integration",
diff --git a/app/javascript/dashboard/routes/dashboard/settings/profile/ActiveSessions.vue b/app/javascript/dashboard/routes/dashboard/settings/profile/ActiveSessions.vue
new file mode 100644
index 000000000..eab050b39
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/profile/ActiveSessions.vue
@@ -0,0 +1,131 @@
+
+
+
+
+
+
+
+
+
+
+ {{ sessionLabel(session) }}
+
+
+ {{ $t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.CURRENT') }}
+
+
+
+ {{ locationLabel(session) }}
+
+
+ {{ $t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.LAST_ACTIVE') }}
+ {{ relativeTime(session.last_activity_at) }}
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue
index 75eb8a2f8..04de7bda2 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue
@@ -20,6 +20,7 @@ import SectionLayout from '../account/components/SectionLayout.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import AccessToken from './AccessToken.vue';
import MfaSettingsCard from './MfaSettingsCard.vue';
+import ActiveSessions from './ActiveSessions.vue';
import Policy from 'dashboard/components/policy.vue';
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
import {
@@ -42,6 +43,7 @@ export default {
AudioNotifications,
AccessToken,
MfaSettingsCard,
+ ActiveSessions,
BaseSettingsHeader,
},
setup() {
@@ -307,6 +309,13 @@ export default {
>
+
+
+
{
+ if (result?.sessionsLimitReached) {
+ this.loginApi.showLoading = false;
+ this.sessionsLimitReached = true;
+ this.limitedSessions = result.sessions;
+ return;
+ }
+ this.handleImpersonation();
+ this.showAlertMessage(this.$t('LOGIN.API.SUCCESS_MESSAGE'));
+ })
+ .catch(response => {
+ this.loginApi.hasErrored = true;
+ this.showAlertMessage(
+ response?.message || this.$t('LOGIN.API.UNAUTH')
+ );
+ });
+ },
+ handleSessionRevoke(sessionId) {
+ this.retryLoginWithParams({ revoke_session_id: sessionId });
+ },
+ handleSessionRevokeAll() {
+ this.retryLoginWithParams({ revoke_all_sessions: true });
+ },
+ handleSessionLimitCancel() {
+ this.sessionsLimitReached = false;
+ this.limitedSessions = [];
+ this.credentials.password = '';
+ },
},
};
@@ -255,8 +311,18 @@ export default {
+
+
+
-
+
{ order('lower(name) ASC') }
@@ -214,6 +216,11 @@ class User < ApplicationRecord
private
+ def sync_user_sessions
+ active_client_ids = (tokens || {}).keys
+ user_sessions.where.not(client_id: active_client_ids).destroy_all
+ end
+
def remove_macros
macros.personal.destroy_all
end
diff --git a/app/models/user_session.rb b/app/models/user_session.rb
new file mode 100644
index 000000000..0f2503382
--- /dev/null
+++ b/app/models/user_session.rb
@@ -0,0 +1,46 @@
+# == Schema Information
+#
+# Table name: user_sessions
+#
+# id :bigint not null, primary key
+# browser_name :string
+# browser_version :string
+# city :string
+# country :string
+# country_code :string
+# device_name :string
+# ip_address :string
+# last_activity_at :datetime
+# platform_name :string
+# platform_version :string
+# user_agent :string
+# created_at :datetime not null
+# updated_at :datetime not null
+# client_id :string not null
+# user_id :bigint not null
+#
+# Indexes
+#
+# index_user_sessions_on_user_id (user_id)
+# index_user_sessions_on_user_id_and_client_id (user_id,client_id) UNIQUE
+#
+# Foreign Keys
+#
+# fk_rails_... (user_id => users.id)
+#
+
+class UserSession < ApplicationRecord
+ ACTIVITY_THROTTLE = 5.minutes
+
+ belongs_to :user
+
+ validates :client_id, presence: true, uniqueness: { scope: :user_id }
+
+ def current?(active_client_id)
+ client_id == active_client_id
+ end
+
+ def should_update_activity?
+ last_activity_at.nil? || last_activity_at < ACTIVITY_THROTTLE.ago
+ end
+end
diff --git a/app/services/user_session_tracking_service.rb b/app/services/user_session_tracking_service.rb
new file mode 100644
index 000000000..537c046e2
--- /dev/null
+++ b/app/services/user_session_tracking_service.rb
@@ -0,0 +1,42 @@
+class UserSessionTrackingService
+ def initialize(user:, request:, client_id:)
+ @user = user
+ @request = request
+ @client_id = client_id
+ end
+
+ def create_or_update!
+ session = @user.user_sessions.find_or_initialize_by(client_id: @client_id)
+ session.assign_attributes(session_attributes)
+ session.last_activity_at = Time.current
+ session.save!
+ session
+ end
+
+ def update_activity!
+ session = @user.user_sessions.find_by(client_id: @client_id)
+ return unless session&.should_update_activity?
+
+ session.update_columns(last_activity_at: Time.current) # rubocop:disable Rails/SkipsModelValidations
+ end
+
+ private
+
+ def session_attributes
+ browser = Browser.new(@request.user_agent)
+ location = IpLookupService.new.perform(@request.remote_ip)
+
+ {
+ ip_address: @request.remote_ip,
+ user_agent: @request.user_agent,
+ browser_name: browser.name,
+ browser_version: browser.full_version,
+ device_name: browser.device.name,
+ platform_name: browser.platform.name,
+ platform_version: browser.platform.version,
+ city: location&.city,
+ country: location&.country,
+ country_code: location&.country_code
+ }
+ end
+end
diff --git a/app/views/api/v1/profile/sessions/index.json.jbuilder b/app/views/api/v1/profile/sessions/index.json.jbuilder
new file mode 100644
index 000000000..b009271e0
--- /dev/null
+++ b/app/views/api/v1/profile/sessions/index.json.jbuilder
@@ -0,0 +1,15 @@
+json.array! @sessions do |session|
+ json.id session.id
+ json.browser_name session.browser_name
+ json.browser_version session.browser_version
+ json.device_name session.device_name
+ json.platform_name session.platform_name
+ json.platform_version session.platform_version
+ json.ip_address session.ip_address
+ json.city session.city
+ json.country session.country
+ json.country_code session.country_code
+ json.last_activity_at session.last_activity_at
+ json.created_at session.created_at
+ json.current session.current?(@current_client_id)
+end
diff --git a/config/initializers/devise_token_auth.rb b/config/initializers/devise_token_auth.rb
index e8500019b..fd3b28fdc 100644
--- a/config/initializers/devise_token_auth.rb
+++ b/config/initializers/devise_token_auth.rb
@@ -15,7 +15,7 @@ DeviseTokenAuth.setup do |config|
# Sets the max number of concurrent devices per user, which is 10 by default.
# After this limit is reached, the oldest tokens will be removed.
- config.max_number_of_devices = 25
+ config.max_number_of_devices = 10
# Sometimes it's necessary to make several requests to the API at the same
# time. In this case, each request in the batch will need to share the same
diff --git a/config/locales/en.yml b/config/locales/en.yml
index b11a4fdae..465a130f9 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -47,6 +47,10 @@ en:
saml_not_available: SAML authentication is not available in this installation.
inbox_deletetion_response: Your inbox deletion request will be processed in some time.
+ profile_settings:
+ sessions:
+ cannot_revoke_current: You cannot revoke the current session.
+
errors:
account:
reporting_timezone:
diff --git a/config/routes.rb b/config/routes.rb
index aeb7fe525..a8dbb3384 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -433,6 +433,7 @@ Rails.application.routes.draw do
post :verify
post :backup_codes
end
+ resources :sessions, only: [:index, :destroy]
end
end
diff --git a/db/migrate/20260523000000_create_user_sessions.rb b/db/migrate/20260523000000_create_user_sessions.rb
new file mode 100644
index 000000000..96ad2821e
--- /dev/null
+++ b/db/migrate/20260523000000_create_user_sessions.rb
@@ -0,0 +1,23 @@
+class CreateUserSessions < ActiveRecord::Migration[7.0]
+ def change
+ create_table :user_sessions do |t|
+ t.references :user, null: false, foreign_key: true
+ t.string :client_id, null: false
+ t.string :ip_address
+ t.string :user_agent
+ t.string :browser_name
+ t.string :browser_version
+ t.string :device_name
+ t.string :platform_name
+ t.string :platform_version
+ t.string :city
+ t.string :country
+ t.string :country_code
+ t.datetime :last_activity_at
+
+ t.timestamps
+ end
+
+ add_index :user_sessions, [:user_id, :client_id], unique: true
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index f2c479571..07b8d7bb7 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -1252,6 +1252,26 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_25_093000) do
t.index ["name", "account_id"], name: "index_teams_on_name_and_account_id", unique: true
end
+ create_table "user_sessions", force: :cascade do |t|
+ t.bigint "user_id", null: false
+ t.string "client_id", null: false
+ t.string "ip_address"
+ t.string "user_agent"
+ t.string "browser_name"
+ t.string "browser_version"
+ t.string "device_name"
+ t.string "platform_name"
+ t.string "platform_version"
+ t.string "city"
+ t.string "country"
+ t.string "country_code"
+ t.datetime "last_activity_at"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["user_id", "client_id"], name: "index_user_sessions_on_user_id_and_client_id", unique: true
+ t.index ["user_id"], name: "index_user_sessions_on_user_id"
+ end
+
create_table "users", id: :serial, force: :cascade do |t|
t.string "provider", default: "email", null: false
t.string "uid", default: "", null: false
@@ -1324,6 +1344,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_25_093000) do
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "inboxes", "portals"
+ add_foreign_key "user_sessions", "users"
create_trigger("accounts_after_insert_row_tr", :generated => true, :compatibility => 1).
on("accounts").
after(:insert).