diff --git a/Gemfile.lock b/Gemfile.lock index 141afc122..8d6132849 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -582,7 +582,7 @@ GEM uri (>= 0.11.1) net-http-persistent (4.0.2) connection_pool (~> 2.2) - net-imap (0.4.24) + net-imap (0.6.4.1) date net-protocol net-pop (0.1.2) 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..72e9451eb --- /dev/null +++ b/app/controllers/api/v1/profile/sessions_controller.rb @@ -0,0 +1,36 @@ +class Api::V1::Profile::SessionsController < Api::BaseController + before_action :set_session, only: [:destroy] + + def index + @sessions = current_user.user_sessions.where(client_id: active_token_client_ids).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 + + def active_token_client_ids + now = Time.current.to_i + (current_user.tokens || {}).select { |_, v| v['expiry'].to_i > now }.keys + 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..1ef6d9511 100644 --- a/app/controllers/devise_overrides/sessions_controller.rb +++ b/app/controllers/devise_overrides/sessions_controller.rb @@ -20,6 +20,7 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController end def render_create_success + track_user_session render partial: 'devise/auth', formats: [:json], locals: { resource: @resource } end @@ -114,6 +115,19 @@ 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 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-next/sidebar/Sidebar.vue b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue index f71652ca0..ab037618c 100644 --- a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue +++ b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue @@ -300,6 +300,7 @@ const menuItems = computed(() => { { name: 'All', label: t('SIDEBAR.ALL_CONVERSATIONS'), + icon: 'i-lucide-inbox', badgeCount: allUnreadCount.value, activeOn: ['inbox_conversation'], to: accountScopedRoute('home'), @@ -307,12 +308,14 @@ const menuItems = computed(() => { { name: 'Mentions', label: t('SIDEBAR.MENTIONED_CONVERSATIONS'), + icon: 'i-lucide-at-sign', activeOn: ['conversation_through_mentions'], to: accountScopedRoute('conversation_mentions'), }, { name: 'Participating', label: t('SIDEBAR.PARTICIPATING_CONVERSATIONS'), + icon: 'i-lucide-user-round-check', activeOn: ['conversation_through_participating'], to: accountScopedRoute('conversation_participating'), }, @@ -320,6 +323,7 @@ const menuItems = computed(() => { name: 'Unattended', activeOn: ['conversation_through_unattended'], label: t('SIDEBAR.UNATTENDED_CONVERSATIONS'), + icon: 'i-lucide-clock-alert', to: accountScopedRoute('conversation_unattended'), }, { @@ -327,6 +331,8 @@ const menuItems = computed(() => { label: t('SIDEBAR.CUSTOM_VIEWS_FOLDER'), icon: 'i-lucide-folder', activeOn: ['conversations_through_folders'], + collapsible: true, + showTreeLine: true, children: conversationCustomViews.value.map(view => ({ name: `${view.name}-${view.id}`, label: view.name, @@ -338,6 +344,8 @@ const menuItems = computed(() => { label: t('SIDEBAR.TEAMS'), icon: 'i-lucide-users', activeOn: ['conversations_through_team'], + collapsible: true, + showTreeLine: true, children: sortedTeams.value.map(team => ({ name: `${team.name}-${team.id}`, label: team.name, @@ -350,6 +358,8 @@ const menuItems = computed(() => { label: t('SIDEBAR.CHANNELS'), icon: 'i-lucide-mailbox', activeOn: ['conversation_through_inbox'], + collapsible: true, + showTreeLine: true, children: sortedInboxes.value.map(inbox => ({ name: `${inbox.name}-${inbox.id}`, label: inbox.name, @@ -370,6 +380,8 @@ const menuItems = computed(() => { label: t('SIDEBAR.LABELS'), icon: 'i-lucide-tag', activeOn: ['conversations_through_label'], + collapsible: true, + showTreeLine: true, children: sortedLabels.value.map(label => ({ name: `${label.title}-${label.id}`, label: label.title, @@ -481,6 +493,8 @@ const menuItems = computed(() => { name: 'Segments', icon: 'i-lucide-group', label: t('SIDEBAR.CUSTOM_VIEWS_SEGMENTS'), + collapsible: true, + showTreeLine: true, children: contactCustomViews.value.map(view => ({ name: `${view.name}-${view.id}`, label: view.name, @@ -499,6 +513,8 @@ const menuItems = computed(() => { name: 'Tagged With', icon: 'i-lucide-tag', label: t('SIDEBAR.TAGGED_WITH'), + collapsible: true, + showTreeLine: true, children: labels.value.map(label => ({ name: `${label.title}-${label.id}`, label: label.title, diff --git a/app/javascript/dashboard/components-next/sidebar/SidebarGroup.vue b/app/javascript/dashboard/components-next/sidebar/SidebarGroup.vue index 048a99cf8..f618ad9ba 100644 --- a/app/javascript/dashboard/components-next/sidebar/SidebarGroup.vue +++ b/app/javascript/dashboard/components-next/sidebar/SidebarGroup.vue @@ -99,20 +99,39 @@ const handleWindowBlur = () => { closeActivePopover(); }; -const accessibleItems = computed(() => { +const hasAccessibleSubChildren = child => { + return child.children?.some( + subChild => subChild.to && isAllowed(subChild.to) + ); +}; + +const visibleChildren = computed(() => { if (!hasChildren.value) return []; + return props.children.filter(child => { - // If a item has no link, it means it's just a subgroup header - // So we don't need to check for permissions here, because there's nothing to - // access here anyway + if (child.children) return hasAccessibleSubChildren(child); + return child.to && isAllowed(child.to); }); }); -const hasAccessibleChildren = computed(() => { - return accessibleItems.value.length > 0; +const accessibleItems = computed(() => { + if (!hasChildren.value) return []; + + return visibleChildren.value + .flatMap(child => child.children || child) + .filter(child => child.to && isAllowed(child.to)); }); +const hasAccessibleChildren = computed(() => { + return visibleChildren.value.length > 0; +}); + +const isLastVisibleChild = child => { + const lastChild = visibleChildren.value[visibleChildren.value.length - 1]; + return lastChild === child; +}; + const isActive = computed(() => { if (props.to) { if (route.path === resolvePath(props.to)) return true; @@ -274,14 +293,18 @@ watch(