Merge branch 'develop' into feat/billing-brl-pix-new-users
This commit is contained in:
+1
-1
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -3,6 +3,7 @@ class ApplicationController < ActionController::Base
|
||||
include RequestExceptionHandler
|
||||
include Pundit::Authorization
|
||||
include SwitchLocale
|
||||
include TrackSessionActivity
|
||||
|
||||
skip_before_action :verify_authenticity_token
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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')
|
||||
|
||||
@@ -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}`);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
<ul
|
||||
v-if="hasChildren"
|
||||
v-show="isExpanded || hasActiveChild"
|
||||
class="grid m-0 list-none sidebar-group-children min-w-0"
|
||||
class="grid m-0 list-none min-w-0"
|
||||
>
|
||||
<template v-for="child in children" :key="child.name">
|
||||
<template v-for="child in visibleChildren" :key="child.name">
|
||||
<SidebarSubGroup
|
||||
v-if="child.children"
|
||||
:name="`${name}:${child.name}`"
|
||||
:label="child.label"
|
||||
:icon="child.icon"
|
||||
:children="child.children"
|
||||
:collapsible="child.collapsible"
|
||||
:show-tree-line="child.showTreeLine"
|
||||
:end-tree-line="child.showTreeLine && isLastVisibleChild(child)"
|
||||
:is-expanded="isExpanded"
|
||||
:active-child="activeChild"
|
||||
/>
|
||||
@@ -299,59 +322,3 @@ watch(
|
||||
</template>
|
||||
</Policy>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.sidebar-group-children .child-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 0.125rem;
|
||||
/* 0.5px */
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sidebar-group-children .child-item:first-child::before {
|
||||
border-radius: 4px 4px 0 0;
|
||||
}
|
||||
|
||||
/* This selects the last child in a group */
|
||||
/* https://codepen.io/scmmishra/pen/yLmKNLW */
|
||||
.sidebar-group-children > .child-item:last-child::before,
|
||||
.sidebar-group-children
|
||||
> *:last-child
|
||||
> *:last-child
|
||||
> .child-item:last-child::before {
|
||||
height: 20%;
|
||||
}
|
||||
|
||||
.sidebar-group-children > .child-item:last-child::after,
|
||||
.sidebar-group-children
|
||||
> *:last-child
|
||||
> *:last-child
|
||||
> .child-item:last-child::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 12px;
|
||||
bottom: calc(50% - 2px);
|
||||
border-bottom-width: 0.125rem;
|
||||
border-left-width: 0.125rem;
|
||||
border-right-width: 0px;
|
||||
border-top-width: 0px;
|
||||
border-radius: 0 0 0 4px;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
#app[dir='rtl'] .sidebar-group-children > .child-item:last-child::after,
|
||||
#app[dir='rtl']
|
||||
.sidebar-group-children
|
||||
> *:last-child
|
||||
> *:last-child
|
||||
> .child-item:last-child::after {
|
||||
right: 0;
|
||||
border-bottom-width: 0.125rem;
|
||||
border-right-width: 0.125rem;
|
||||
border-left-width: 0px;
|
||||
border-top-width: 0px;
|
||||
border-radius: 0 0 4px 0px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -12,6 +12,8 @@ const props = defineProps({
|
||||
active: { type: Boolean, default: false },
|
||||
component: { type: Function, default: null },
|
||||
badgeCount: { type: [Number, String], default: 0 },
|
||||
hideTreeLine: { type: Boolean, default: false },
|
||||
thinTreeLine: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const { resolvePermissions, resolveFeatureFlag } = useSidebarContext();
|
||||
@@ -19,21 +21,30 @@ const { resolvePermissions, resolveFeatureFlag } = useSidebarContext();
|
||||
const shouldRenderComponent = computed(() => {
|
||||
return typeof props.component === 'function' || isVNode(props.component);
|
||||
});
|
||||
|
||||
// Tree-line connector per leaf: vertical line (::before) + rounded elbow on the
|
||||
// last child (::after). Logical props (start / border-s / rounded-es)
|
||||
const TREE_CONNECTOR =
|
||||
"child-item before:content-[''] before:absolute before:start-0 before:w-0.5 before:h-full before:bg-n-slate-4 first:before:rounded-t last:before:h-1/5 last:after:content-[''] last:after:absolute last:after:start-0 last:after:bottom-[calc(50%_-_2px)] last:after:h-3 last:after:w-2.5 last:after:border-b-2 last:after:border-s-2 last:after:rounded-es last:after:border-n-slate-4";
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
||||
<template>
|
||||
<Policy
|
||||
:permissions="resolvePermissions(to)"
|
||||
:feature-flag="resolveFeatureFlag(to)"
|
||||
as="li"
|
||||
class="py-0.5 ltr:pl-2 rtl:pr-2 rtl:mr-3 ltr:ml-3 relative text-n-slate-11 child-item before:bg-n-slate-4 after:bg-transparent after:border-n-slate-4 before:left-0 rtl:before:right-0 min-w-0"
|
||||
class="py-0.5 ps-2 ms-3 relative text-n-slate-11 min-w-0"
|
||||
:class="{
|
||||
[TREE_CONNECTOR]: !hideTreeLine,
|
||||
'before:!w-px last:after:!border-b last:after:!border-s':
|
||||
!hideTreeLine && thinTreeLine,
|
||||
}"
|
||||
>
|
||||
<component
|
||||
:is="to ? 'router-link' : 'div'"
|
||||
:to="to"
|
||||
:title="label"
|
||||
class="flex h-8 items-center gap-2 px-2 py-1 rounded-lg hover:bg-gradient-to-r from-transparent via-n-slate-3/70 to-n-slate-3/70 group min-w-0"
|
||||
class="flex h-8 items-center gap-2 px-2 py-1 rounded-lg ltr:hover:bg-gradient-to-r rtl:hover:bg-gradient-to-l from-transparent via-n-slate-3/70 to-n-slate-3/70 group min-w-0"
|
||||
:class="{
|
||||
'text-n-slate-12 bg-n-alpha-2 active': active,
|
||||
}"
|
||||
|
||||
@@ -2,24 +2,69 @@
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
|
||||
defineProps({
|
||||
collapsible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isExpanded: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
type: [Object, String],
|
||||
default: '',
|
||||
},
|
||||
showTreeLine: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
endTreeLine: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['toggle']);
|
||||
|
||||
const TREE_VERTICAL_LINE =
|
||||
"before:content-[''] before:absolute before:-top-1 before:w-0.5 before:bg-n-slate-4 before:start-[-0.5rem]";
|
||||
const TREE_ELBOW =
|
||||
"after:content-[''] after:absolute after:w-2.5 after:h-3 after:bottom-1/2 after:start-[-0.5rem] after:border-b-2 after:border-s-2 after:rounded-es after:border-n-slate-4";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center gap-2 px-2 py-1.5 rounded-lg h-8 text-n-slate-10 select-none pointer-events-none"
|
||||
<component
|
||||
:is="collapsible ? 'button' : 'div'"
|
||||
:type="collapsible ? 'button' : undefined"
|
||||
:aria-expanded="collapsible ? isExpanded : undefined"
|
||||
:title="label"
|
||||
class="relative justify-between flex items-center gap-2 px-2 py-1.5 rounded-lg h-8 text-n-slate-10 select-none min-w-0"
|
||||
:class="[
|
||||
showTreeLine && TREE_VERTICAL_LINE,
|
||||
showTreeLine &&
|
||||
(endTreeLine ? `before:h-3 ${TREE_ELBOW}` : 'before:-bottom-1'),
|
||||
{
|
||||
'w-full': !collapsible,
|
||||
'pointer-events-none': !collapsible,
|
||||
'ms-5 cursor-pointer hover:bg-n-alpha-2': collapsible,
|
||||
},
|
||||
]"
|
||||
@click.stop="emit('toggle')"
|
||||
>
|
||||
<Icon v-if="icon" :icon="icon" class="size-4" />
|
||||
<span class="text-sm font-medium leading-5 flex-grow">
|
||||
{{ label }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="min-w-0 inline-flex gap-2 items-center">
|
||||
<Icon v-if="icon" :icon="icon" class="size-4 flex-shrink-0" />
|
||||
<span class="text-sm font-medium leading-5 flex-grow truncate text-start">
|
||||
{{ label }}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
v-if="collapsible"
|
||||
class="size-3 flex-shrink-0 text-n-slate-10 me-0.5"
|
||||
:class="isExpanded ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
||||
/>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import SidebarGroupLeaf from './SidebarGroupLeaf.vue';
|
||||
import SidebarGroupSeparator from './SidebarGroupSeparator.vue';
|
||||
|
||||
@@ -7,15 +11,42 @@ import { useSidebarContext } from './provider';
|
||||
import { useEventListener } from '@vueuse/core';
|
||||
|
||||
const props = defineProps({
|
||||
name: { type: String, required: true },
|
||||
isExpanded: { type: Boolean, default: false },
|
||||
label: { type: String, required: true },
|
||||
icon: { type: [Object, String], required: true },
|
||||
children: { type: Array, default: undefined },
|
||||
activeChild: { type: Object, default: undefined },
|
||||
collapsible: { type: Boolean, default: false },
|
||||
showTreeLine: { type: Boolean, default: false },
|
||||
endTreeLine: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const { isAllowed } = useSidebarContext();
|
||||
const scrollableContainer = ref(null);
|
||||
const accountId = useMapGetter('getCurrentAccountId');
|
||||
|
||||
const minimizedSectionsKey = LOCAL_STORAGE_KEYS.SIDEBAR_MINIMIZED_SECTIONS;
|
||||
|
||||
const getMinimizedSections = () => {
|
||||
const minimizedSections = LocalStorage.get(minimizedSectionsKey);
|
||||
return minimizedSections &&
|
||||
typeof minimizedSections === 'object' &&
|
||||
!Array.isArray(minimizedSections)
|
||||
? minimizedSections
|
||||
: {};
|
||||
};
|
||||
|
||||
const minimizedSections = ref(getMinimizedSections());
|
||||
const storageKey = computed(() =>
|
||||
accountId.value ? `${accountId.value}:${props.name}` : props.name
|
||||
);
|
||||
const isSubGroupExpanded = computed(
|
||||
() => !props.collapsible || !minimizedSections.value[storageKey.value]
|
||||
);
|
||||
const hasActiveChild = computed(() =>
|
||||
props.children.some(child => child.name === props.activeChild?.name)
|
||||
);
|
||||
|
||||
const accessibleItems = computed(() =>
|
||||
props.children.filter(child => {
|
||||
@@ -28,84 +59,117 @@ const hasAccessibleItems = computed(() => {
|
||||
});
|
||||
|
||||
const isScrollable = computed(() => {
|
||||
return accessibleItems.value.length > 7;
|
||||
return (
|
||||
props.isExpanded &&
|
||||
isSubGroupExpanded.value &&
|
||||
accessibleItems.value.length > 7
|
||||
);
|
||||
});
|
||||
|
||||
const scrollEnd = ref(false);
|
||||
|
||||
const CHILDREN_TRUNK =
|
||||
"before:content-[''] before:absolute before:top-0 before:bottom-0 before:w-0.5 before:bg-n-slate-4 before:start-[-0.5rem]";
|
||||
|
||||
const hideLeafTreeLine = computed(
|
||||
() => props.showTreeLine && !props.isExpanded
|
||||
);
|
||||
|
||||
const toggleSubGroup = () => {
|
||||
if (!props.collapsible) return;
|
||||
|
||||
if (isSubGroupExpanded.value) {
|
||||
LocalStorage.updateJsonStore(minimizedSectionsKey, storageKey.value, true);
|
||||
} else {
|
||||
LocalStorage.deleteFromJsonStore(minimizedSectionsKey, storageKey.value);
|
||||
}
|
||||
|
||||
minimizedSections.value = getMinimizedSections();
|
||||
};
|
||||
|
||||
const expandSubGroupOnActiveChild = () => {
|
||||
if (!props.collapsible || !hasActiveChild.value || isSubGroupExpanded.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
LocalStorage.deleteFromJsonStore(minimizedSectionsKey, storageKey.value);
|
||||
minimizedSections.value = getMinimizedSections();
|
||||
};
|
||||
|
||||
const shouldShowItem = child => {
|
||||
return (
|
||||
isSubGroupExpanded.value &&
|
||||
(props.isExpanded || props.activeChild?.name === child.name)
|
||||
);
|
||||
};
|
||||
|
||||
// set scrollEnd to true when the scroll reaches the end
|
||||
useEventListener(scrollableContainer, 'scroll', () => {
|
||||
const { scrollHeight, scrollTop, clientHeight } = scrollableContainer.value;
|
||||
scrollEnd.value = scrollHeight - scrollTop === clientHeight;
|
||||
});
|
||||
|
||||
useEventListener(window, 'storage', event => {
|
||||
if (event.key === minimizedSectionsKey) {
|
||||
minimizedSections.value = getMinimizedSections();
|
||||
}
|
||||
});
|
||||
|
||||
watch([hasActiveChild, storageKey], expandSubGroupOnActiveChild, {
|
||||
immediate: true,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SidebarGroupSeparator
|
||||
v-if="hasAccessibleItems"
|
||||
v-show="isExpanded"
|
||||
:label
|
||||
:icon
|
||||
class="my-1"
|
||||
/>
|
||||
<ul
|
||||
v-if="children.length"
|
||||
class="m-0 list-none reset-base relative group min-w-0"
|
||||
>
|
||||
<!-- Each element has h-8, which is 32px, we will show 7 items with one hidden at the end,
|
||||
which is 14rem. Then we add 16px so that we have some text visible from the next item -->
|
||||
<div
|
||||
ref="scrollableContainer"
|
||||
class="min-w-0"
|
||||
:class="{
|
||||
'max-h-[calc(14rem+16px)] overflow-y-scroll no-scrollbar': isScrollable,
|
||||
}"
|
||||
>
|
||||
<SidebarGroupLeaf
|
||||
v-for="child in children"
|
||||
v-show="isExpanded || activeChild?.name === child.name"
|
||||
v-bind="child"
|
||||
:key="child.name"
|
||||
:active="activeChild?.name === child.name"
|
||||
<li class="relative flex flex-col list-none min-w-0">
|
||||
<template v-if="hasAccessibleItems">
|
||||
<SidebarGroupSeparator
|
||||
v-show="isExpanded"
|
||||
:label
|
||||
:icon
|
||||
:collapsible
|
||||
:is-expanded="isSubGroupExpanded"
|
||||
:show-tree-line="showTreeLine"
|
||||
:end-tree-line="endTreeLine"
|
||||
class="my-1"
|
||||
@toggle="toggleSubGroup"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="isScrollable && isExpanded"
|
||||
v-show="!scrollEnd"
|
||||
class="absolute bg-gradient-to-t from-n-background w-full h-12 to-transparent -bottom-1 pointer-events-none flex items-end justify-end px-2 animate-fade-in-up"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="24"
|
||||
viewBox="0 0 16 24"
|
||||
fill="none"
|
||||
class="text-n-slate-9 opacity-50 group-hover:opacity-100"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
<ul
|
||||
v-if="children.length"
|
||||
class="m-0 list-none reset-base relative group min-w-0"
|
||||
:class="[
|
||||
{ 'ms-5': collapsible },
|
||||
showTreeLine && !endTreeLine && CHILDREN_TRUNK,
|
||||
]"
|
||||
>
|
||||
<path
|
||||
d="M4 4L8 8L12 4"
|
||||
stroke="currentColor"
|
||||
opacity="0.5"
|
||||
stroke-width="1.33333"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M4 10L8 14L12 10"
|
||||
stroke="currentColor"
|
||||
opacity="0.75"
|
||||
stroke-width="1.33333"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M4 16L8 20L12 16"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.33333"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</ul>
|
||||
<div
|
||||
ref="scrollableContainer"
|
||||
class="min-w-0"
|
||||
:class="{
|
||||
'max-h-60 overflow-y-scroll no-scrollbar': isScrollable,
|
||||
}"
|
||||
>
|
||||
<SidebarGroupLeaf
|
||||
v-for="child in children"
|
||||
v-show="shouldShowItem(child)"
|
||||
v-bind="child"
|
||||
:key="child.name"
|
||||
:active="activeChild?.name === child.name"
|
||||
:hide-tree-line="hideLeafTreeLine"
|
||||
thin-tree-line
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="isScrollable && isExpanded"
|
||||
v-show="!scrollEnd"
|
||||
class="absolute bg-gradient-to-t from-n-background w-full h-12 to-transparent -bottom-1 pointer-events-none flex items-end justify-end px-2 animate-fade-in-up"
|
||||
>
|
||||
<Icon
|
||||
icon="i-woot-chevrons-down"
|
||||
class="w-4 h-6 text-n-slate-9 opacity-50 group-hover:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
</ul>
|
||||
</template>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { ref } from 'vue';
|
||||
import SidebarSubGroup from '../SidebarSubGroup.vue';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { provideSidebarContext } from '../provider';
|
||||
|
||||
vi.mock('dashboard/composables/store', () => ({
|
||||
useMapGetter: () => ref(1),
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/composables/usePolicy', () => ({
|
||||
usePolicy: () => ({
|
||||
shouldShow: () => true,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({
|
||||
resolve: () => ({ path: '/' }),
|
||||
getRoutes: () => [],
|
||||
}),
|
||||
}));
|
||||
|
||||
const children = [
|
||||
{
|
||||
name: 'Sales-1',
|
||||
label: 'Sales',
|
||||
to: { name: 'team_conversations' },
|
||||
},
|
||||
];
|
||||
|
||||
const mountSubGroup = props => {
|
||||
return mount(
|
||||
{
|
||||
components: { SidebarSubGroup },
|
||||
setup() {
|
||||
provideSidebarContext({});
|
||||
},
|
||||
template: '<SidebarSubGroup v-bind="$attrs" />',
|
||||
},
|
||||
{
|
||||
attrs: {
|
||||
name: 'Conversation:Teams',
|
||||
label: 'Teams',
|
||||
icon: 'i-lucide-users',
|
||||
children,
|
||||
isExpanded: true,
|
||||
collapsible: true,
|
||||
...props,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
SidebarGroupLeaf: {
|
||||
props: {
|
||||
label: { type: String, required: true },
|
||||
hideTreeLine: { type: Boolean, default: false },
|
||||
thinTreeLine: { type: Boolean, default: false },
|
||||
},
|
||||
template:
|
||||
'<li class="sidebar-leaf" :data-hide-tree-line="String(hideTreeLine)" :data-thin-tree-line="String(thinTreeLine)">{{ label }}</li>',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
describe('SidebarSubGroup', () => {
|
||||
let localStorageStore;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorageStore = {};
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: {
|
||||
getItem: key => localStorageStore[key] || null,
|
||||
setItem: (key, value) => {
|
||||
localStorageStore[key] = String(value);
|
||||
},
|
||||
removeItem: key => {
|
||||
delete localStorageStore[key];
|
||||
},
|
||||
clear: () => {
|
||||
localStorageStore = {};
|
||||
},
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it('keeps collapsible sections expanded by default', () => {
|
||||
const wrapper = mountSubGroup();
|
||||
|
||||
expect(wrapper.find('button').attributes('aria-expanded')).toBe('true');
|
||||
expect(wrapper.find('.sidebar-leaf').isVisible()).toBe(true);
|
||||
});
|
||||
|
||||
it('renders the tree line on the separator, positioned relative to it', () => {
|
||||
const wrapper = mountSubGroup({ showTreeLine: true });
|
||||
const button = wrapper.find('button');
|
||||
|
||||
expect(button.classes()).toContain('relative');
|
||||
expect(button.classes()).toContain('before:bg-n-slate-4');
|
||||
expect(button.classes()).toContain('before:-bottom-1');
|
||||
});
|
||||
|
||||
it('renders the end curve on the last separator', () => {
|
||||
const wrapper = mountSubGroup({ showTreeLine: true, endTreeLine: true });
|
||||
const button = wrapper.find('button');
|
||||
|
||||
expect(button.classes()).toContain('before:h-3');
|
||||
expect(button.classes()).toContain('after:border-b-2');
|
||||
});
|
||||
|
||||
it('draws nested item tree lines via the leaf connectors', () => {
|
||||
const wrapper = mountSubGroup({ showTreeLine: true });
|
||||
|
||||
expect(
|
||||
wrapper.find('.sidebar-leaf').attributes('data-hide-tree-line')
|
||||
).toBe('false');
|
||||
});
|
||||
|
||||
it('marks nested item connectors as thin', () => {
|
||||
const wrapper = mountSubGroup({ showTreeLine: true });
|
||||
|
||||
expect(
|
||||
wrapper.find('.sidebar-leaf').attributes('data-thin-tree-line')
|
||||
).toBe('true');
|
||||
});
|
||||
|
||||
it('minimizes the section and stores it by account and section name', async () => {
|
||||
const wrapper = mountSubGroup();
|
||||
|
||||
await wrapper.find('button').trigger('click');
|
||||
|
||||
const storedSections = JSON.parse(
|
||||
window.localStorage.getItem(LOCAL_STORAGE_KEYS.SIDEBAR_MINIMIZED_SECTIONS)
|
||||
);
|
||||
expect(wrapper.find('button').attributes('aria-expanded')).toBe('false');
|
||||
expect(wrapper.find('.sidebar-leaf').isVisible()).toBe(false);
|
||||
expect(storedSections).toEqual({ '1:Conversation:Teams': true });
|
||||
});
|
||||
|
||||
it('uses the stored minimized state when mounted again', () => {
|
||||
window.localStorage.setItem(
|
||||
LOCAL_STORAGE_KEYS.SIDEBAR_MINIMIZED_SECTIONS,
|
||||
JSON.stringify({ '1:Conversation:Teams': true })
|
||||
);
|
||||
|
||||
const wrapper = mountSubGroup();
|
||||
|
||||
expect(wrapper.find('button').attributes('aria-expanded')).toBe('false');
|
||||
expect(wrapper.find('.sidebar-leaf').isVisible()).toBe(false);
|
||||
});
|
||||
|
||||
it('expands a stored minimized section when one of its children is active', () => {
|
||||
window.localStorage.setItem(
|
||||
LOCAL_STORAGE_KEYS.SIDEBAR_MINIMIZED_SECTIONS,
|
||||
JSON.stringify({ '1:Conversation:Teams': true })
|
||||
);
|
||||
|
||||
const wrapper = mountSubGroup({ activeChild: children[0] });
|
||||
|
||||
const storedSections = JSON.parse(
|
||||
window.localStorage.getItem(LOCAL_STORAGE_KEYS.SIDEBAR_MINIMIZED_SECTIONS)
|
||||
);
|
||||
expect(wrapper.find('button').attributes('aria-expanded')).toBe('true');
|
||||
expect(wrapper.find('.sidebar-leaf').isVisible()).toBe(true);
|
||||
expect(storedSections).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -70,7 +70,8 @@ describe('useFacebookPageConnect', () => {
|
||||
ACCOUNT_ID
|
||||
);
|
||||
expect(window.FB.login).toHaveBeenCalledWith(expect.any(Function), {
|
||||
scope: expect.stringContaining('pages_show_list'),
|
||||
scope:
|
||||
'pages_manage_metadata,business_management,pages_messaging,pages_show_list,pages_read_engagement',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { ref } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import ChannelApi from 'dashboard/api/channels';
|
||||
import { buildFacebookLoginScopes } from 'dashboard/helper/facebookScopes';
|
||||
import { setupFacebookSdk } from 'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils';
|
||||
|
||||
// Page-management + messaging scopes required to list pages and create a
|
||||
// Channel::FacebookPage inbox (mirrors the standalone settings flow).
|
||||
const FB_PAGE_SCOPES =
|
||||
'pages_manage_metadata,business_management,pages_messaging,instagram_basic,pages_show_list,pages_read_engagement,instagram_manage_messages';
|
||||
|
||||
// Headless half of the Facebook Page connect flow: load the Meta SDK, run
|
||||
// FB.login for page scopes, and fetch the user's pages. The caller owns the
|
||||
// page-picker UI and the channel creation, because choosing a page is an
|
||||
@@ -51,7 +47,7 @@ export function useFacebookPageConnect() {
|
||||
: null
|
||||
);
|
||||
},
|
||||
{ scope: FB_PAGE_SCOPES }
|
||||
{ scope: buildFacebookLoginScopes() }
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -7,4 +7,5 @@ export const LOCAL_STORAGE_KEYS = {
|
||||
DISMISSED_LABEL_SUGGESTIONS: 'labelSuggestionsDismissed',
|
||||
MESSAGE_REPLY_TO: 'messageReplyTo',
|
||||
RECENT_SEARCHES: 'recentSearches',
|
||||
SIDEBAR_MINIMIZED_SECTIONS: 'sidebarMinimizedSections',
|
||||
};
|
||||
|
||||
@@ -154,6 +154,10 @@ export const YEAR_IN_REVIEW_EVENTS = Object.freeze({
|
||||
SHARE_CLICKED: 'Year in Review: Share clicked',
|
||||
});
|
||||
|
||||
export const SESSION_EVENTS = Object.freeze({
|
||||
REVOKED_FROM_PROFILE: 'Revoked an active session',
|
||||
});
|
||||
|
||||
export const ONBOARDING_EVENTS = Object.freeze({
|
||||
ACCOUNT_DETAILS_VISITED: 'Onboarding: Account details visited',
|
||||
ACCOUNT_DETAILS_COMPLETED: 'Onboarding: Account details completed',
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
export const FACEBOOK_PAGE_SCOPES = [
|
||||
'pages_manage_metadata',
|
||||
'business_management',
|
||||
'pages_messaging',
|
||||
'pages_show_list',
|
||||
'pages_read_engagement',
|
||||
];
|
||||
|
||||
export const INSTAGRAM_SCOPES = [
|
||||
'instagram_basic',
|
||||
'instagram_manage_messages',
|
||||
];
|
||||
|
||||
export const buildFacebookLoginScopes = ({
|
||||
includeInstagramScopes = false,
|
||||
} = {}) => {
|
||||
const scopes = [...FACEBOOK_PAGE_SCOPES];
|
||||
if (includeInstagramScopes) {
|
||||
scopes.push(...INSTAGRAM_SCOPES);
|
||||
}
|
||||
return scopes.join(',');
|
||||
};
|
||||
@@ -86,6 +86,17 @@
|
||||
"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",
|
||||
"UNKNOWN_DEVICE": "Unknown device"
|
||||
},
|
||||
"ACCESS_TOKEN": {
|
||||
"TITLE": "Access Token",
|
||||
"NOTE": "This token can be used if you are building an API based integration",
|
||||
|
||||
@@ -4,6 +4,7 @@ import InboxReconnectionRequired from '../components/InboxReconnectionRequired.v
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
import { loadScript } from 'dashboard/helper/DOMHelpers';
|
||||
import { buildFacebookLoginScopes } from 'dashboard/helper/facebookScopes';
|
||||
import * as Sentry from '@sentry/vue';
|
||||
|
||||
export default {
|
||||
@@ -20,6 +21,11 @@ export default {
|
||||
inboxId() {
|
||||
return this.inbox.id;
|
||||
},
|
||||
facebookLoginScopes() {
|
||||
return buildFacebookLoginScopes({
|
||||
includeInstagramScopes: !!this.inbox.instagram_id,
|
||||
});
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
window.fbAsyncInit = this.runFBInit;
|
||||
@@ -77,8 +83,7 @@ export default {
|
||||
}
|
||||
},
|
||||
{
|
||||
scope:
|
||||
'pages_manage_metadata,business_management,pages_messaging,instagram_basic,pages_show_list,pages_read_engagement,instagram_manage_messages',
|
||||
scope: this.facebookLoginScopes,
|
||||
auth_type: 'reauthorize',
|
||||
}
|
||||
);
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { formatDistanceToNow, parseISO } from 'date-fns';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import authAPI from 'dashboard/api/auth';
|
||||
import AnalyticsHelper from 'dashboard/helper/AnalyticsHelper';
|
||||
import { SESSION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const sessions = ref([]);
|
||||
const loading = ref(false);
|
||||
|
||||
const relativeTime = dateStr => {
|
||||
if (!dateStr) return '';
|
||||
return formatDistanceToNow(parseISO(dateStr), { addSuffix: true });
|
||||
};
|
||||
|
||||
const isUnknown = val => !val || val === 'Unknown' || val === 'Unknown Browser';
|
||||
|
||||
const deviceIcon = session => {
|
||||
const name = (session.device_name || '').toLowerCase();
|
||||
if (
|
||||
name.includes('iphone') ||
|
||||
name.includes('android') ||
|
||||
name.includes('mobile')
|
||||
) {
|
||||
return 'i-lucide-smartphone';
|
||||
}
|
||||
if (name.includes('ipad') || name.includes('tablet')) {
|
||||
return 'i-lucide-tablet';
|
||||
}
|
||||
return 'i-lucide-monitor';
|
||||
};
|
||||
|
||||
const sessionLabel = session => {
|
||||
const parts = [];
|
||||
if (!isUnknown(session.browser_name)) {
|
||||
parts.push(
|
||||
session.browser_version
|
||||
? `${session.browser_name} ${session.browser_version}`
|
||||
: session.browser_name
|
||||
);
|
||||
}
|
||||
if (!isUnknown(session.platform_name)) parts.push(session.platform_name);
|
||||
return (
|
||||
parts.join(' on ') ||
|
||||
t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.UNKNOWN_DEVICE')
|
||||
);
|
||||
};
|
||||
|
||||
const locationLabel = session => {
|
||||
const parts = [];
|
||||
if (session.city) parts.push(session.city);
|
||||
if (session.country) parts.push(session.country);
|
||||
return parts.join(', ');
|
||||
};
|
||||
|
||||
const fetchSessions = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await authAPI.getSessions();
|
||||
sessions.value = data;
|
||||
} catch {
|
||||
useAlert(t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.FETCH_ERROR'));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const revokeSession = async session => {
|
||||
try {
|
||||
await authAPI.revokeSession(session.id);
|
||||
sessions.value = sessions.value.filter(s => s.id !== session.id);
|
||||
AnalyticsHelper.track(SESSION_EVENTS.REVOKED_FROM_PROFILE);
|
||||
useAlert(t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.REVOKE_SUCCESS'));
|
||||
} catch {
|
||||
useAlert(t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.REVOKE_ERROR'));
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(fetchSessions);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div
|
||||
v-for="session in sessions"
|
||||
:key="session.id"
|
||||
class="flex items-center justify-between gap-4 rounded-xl border border-n-slate-4 bg-n-background p-4"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<Icon
|
||||
:icon="deviceIcon(session)"
|
||||
class="size-5 mt-0.5 text-n-slate-10 flex-shrink-0"
|
||||
/>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-heading-3 text-n-slate-12">
|
||||
{{ sessionLabel(session) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="session.current"
|
||||
class="rounded-full bg-n-teal-3 px-2 py-0.5 text-caption text-n-teal-11"
|
||||
>
|
||||
{{ $t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.CURRENT') }}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
v-if="locationLabel(session)"
|
||||
class="text-body-b3 text-n-slate-11"
|
||||
>
|
||||
{{ locationLabel(session) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="session.last_activity_at"
|
||||
class="text-body-b3 text-n-slate-10"
|
||||
>
|
||||
{{ $t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.LAST_ACTIVE') }}
|
||||
{{ relativeTime(session.last_activity_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
v-if="!session.current"
|
||||
type="button"
|
||||
faded
|
||||
xs
|
||||
:label="$t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.REVOKE')"
|
||||
color="ruby"
|
||||
@click="revokeSession(session)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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 {
|
||||
>
|
||||
<MfaSettingsCard />
|
||||
</SectionLayout>
|
||||
<SectionLayout
|
||||
with-border
|
||||
:title="$t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.TITLE')"
|
||||
:description="$t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.NOTE')"
|
||||
>
|
||||
<ActiveSessions />
|
||||
</SectionLayout>
|
||||
<Policy :permissions="audioNotificationPermissions">
|
||||
<SectionLayout
|
||||
with-border
|
||||
|
||||
@@ -25,7 +25,9 @@ export const createConversationPayload = ({ params, contactId, files }) => {
|
||||
payload.append('inbox_id', inboxId);
|
||||
payload.append('contact_id', contactId);
|
||||
payload.append('source_id', sourceId);
|
||||
payload.append('additional_attributes[mail_subject]', mailSubject);
|
||||
if (mailSubject) {
|
||||
payload.append('additional_attributes[mail_subject]', mailSubject);
|
||||
}
|
||||
payload.append('assignee_id', assigneeId);
|
||||
|
||||
return payload;
|
||||
|
||||
@@ -282,6 +282,24 @@ describe('createConversationPayload', () => {
|
||||
expect(payload.get('assignee_id')).toBe(options.params.assigneeId);
|
||||
expect(payload.getAll('message[attachments][]')).toEqual([]);
|
||||
});
|
||||
|
||||
it('omits mail_subject when mailSubject is undefined', () => {
|
||||
const options = {
|
||||
params: {
|
||||
inboxId: '1',
|
||||
message: {
|
||||
content: 'Test message content',
|
||||
},
|
||||
sourceId: '12',
|
||||
assigneeId: '123',
|
||||
},
|
||||
contactId: '23',
|
||||
};
|
||||
|
||||
const payload = createConversationPayload(options);
|
||||
|
||||
expect(payload.has('additional_attributes[mail_subject]')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createWhatsAppConversationPayload', () => {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
class UserSessionIpLookupJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
def perform(session)
|
||||
return if session.ip_address.blank?
|
||||
|
||||
result = IpLookupService.new.perform(session.ip_address)
|
||||
return unless result
|
||||
|
||||
session.update_columns( # rubocop:disable Rails/SkipsModelValidations
|
||||
city: result.city,
|
||||
country: result.country,
|
||||
country_code: result.country_code
|
||||
)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "UserSessionIpLookupJob failed: #{e.message}"
|
||||
end
|
||||
end
|
||||
@@ -69,8 +69,10 @@ class Channel::Whatsapp < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
# Enables voice: turns calling on at Meta (idempotent), subscribes the `calls`
|
||||
# webhook field, and sets calling_enabled. Raises on Meta failure.
|
||||
# Enables voice: turns calling on at Meta (idempotent), then re-registers webhooks
|
||||
# with the in-memory calling_enabled flag so the `calls` field is subscribed. The
|
||||
# flag is persisted only after registration succeeds, so a webhook failure can't
|
||||
# leave the inbox reporting voice_enabled? while the WABA isn't subscribed to calls.
|
||||
# Saved with validate: false to skip validate_provider_config's remote credential
|
||||
# re-check, which could spuriously fail and desync the flag from Meta.
|
||||
def enable_voice_calling!
|
||||
@@ -78,21 +80,21 @@ class Channel::Whatsapp < ApplicationRecord
|
||||
raise 'WhatsApp calling requires the channel_voice feature' unless account.feature_enabled?('channel_voice')
|
||||
|
||||
provider_service.update_calling_status('ENABLED')
|
||||
webhook_setup_service.register_callback
|
||||
self.provider_config = provider_config.merge('calling_enabled' => true)
|
||||
webhook_setup_service.register_callback
|
||||
save!(validate: false)
|
||||
end
|
||||
|
||||
# Disables voice: unsets calling_enabled (gates the call subsystem) and drops
|
||||
# `calls` from the webhook subscription (best-effort, so a Meta outage can't
|
||||
# trap admins). Leaves Meta's WABA calling.status untouched.
|
||||
# Disables voice: unsets calling_enabled (gates the call subsystem) and re-registers
|
||||
# webhooks, which drops `calls` from the subscription (best-effort, so a Meta outage
|
||||
# can't trap admins). Leaves Meta's WABA calling.status untouched.
|
||||
def disable_voice_calling!
|
||||
raise 'WhatsApp calling requires a whatsapp_cloud inbox' unless voice_calling_supported?
|
||||
|
||||
self.provider_config = provider_config.merge('calling_enabled' => false)
|
||||
save!(validate: false)
|
||||
begin
|
||||
webhook_setup_service.register_callback(subscribed_fields: %w[messages smb_message_echoes])
|
||||
webhook_setup_service.register_callback
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "[WHATSAPP CALL] disable webhook re-subscribe failed: #{e.message}"
|
||||
end
|
||||
|
||||
@@ -30,6 +30,10 @@ class Integrations::App
|
||||
params[:fields]
|
||||
end
|
||||
|
||||
def visible_properties
|
||||
Array(params[:visible_properties]).map(&:to_s)
|
||||
end
|
||||
|
||||
# There is no way to get the account_id from the linear callback
|
||||
# so we are using the generate_linear_token method to generate a token and encode it in the state parameter
|
||||
def encode_state
|
||||
|
||||
@@ -101,6 +101,7 @@ class User < ApplicationRecord
|
||||
has_many :messages, as: :sender, dependent: :nullify
|
||||
has_many :invitees, through: :account_users, class_name: 'User', foreign_key: 'inviter_id', source: :inviter, dependent: :nullify
|
||||
|
||||
has_many :user_sessions, dependent: :destroy
|
||||
has_many :custom_filters, dependent: :destroy_async
|
||||
has_many :dashboard_apps, dependent: :nullify
|
||||
has_many :mentions, dependent: :destroy_async
|
||||
@@ -118,6 +119,7 @@ class User < ApplicationRecord
|
||||
|
||||
before_validation :set_password_and_uid, on: :create
|
||||
after_destroy :remove_macros
|
||||
after_save :sync_user_sessions, if: :saved_change_to_tokens?
|
||||
|
||||
scope :order_by_full_name, -> { 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
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,39 @@
|
||||
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!
|
||||
UserSessionIpLookupJob.perform_later(session) if session.ip_address.present?
|
||||
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)
|
||||
|
||||
{
|
||||
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
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -60,7 +60,7 @@ class Whatsapp::FacebookApiClient
|
||||
data['code_verification_status'] == 'VERIFIED'
|
||||
end
|
||||
|
||||
WEBHOOK_DEFAULT_FIELDS = %w[messages smb_message_echoes calls].freeze
|
||||
WEBHOOK_DEFAULT_FIELDS = %w[messages smb_message_echoes].freeze
|
||||
|
||||
def subscribe_waba_webhook(waba_id, callback_url, verify_token, subscribed_fields: WEBHOOK_DEFAULT_FIELDS)
|
||||
# Step 1: Subscribe app to WABA first (required before override)
|
||||
|
||||
@@ -13,8 +13,17 @@ class Whatsapp::IncomingMessageWhatsappCloudService < Whatsapp::IncomingMessageB
|
||||
inbox.channel.media_url(attachment_payload[:id]),
|
||||
headers: inbox.channel.api_headers
|
||||
)
|
||||
|
||||
# This url response will be failure if the access token has expired.
|
||||
inbox.channel.authorization_error! if url_response.unauthorized?
|
||||
Down.download(url_response.parsed_response['url'], headers: inbox.channel.api_headers) if url_response.success?
|
||||
|
||||
return unless url_response.success?
|
||||
|
||||
downloaded_file = Down.download(url_response.parsed_response['url'], headers: inbox.channel.api_headers)
|
||||
# WhatsApp Cloud sends the original filename in the payload; preserve it so accented
|
||||
# names keep their correct extension instead of relying on the mangled remote metadata.
|
||||
filename = attachment_payload[:filename]
|
||||
downloaded_file.define_singleton_method(:original_filename) { filename } if filename.present?
|
||||
downloaded_file
|
||||
end
|
||||
end
|
||||
|
||||
@@ -17,9 +17,9 @@ class Whatsapp::WebhookSetupService
|
||||
setup_webhook
|
||||
end
|
||||
|
||||
def register_callback(subscribed_fields: nil)
|
||||
def register_callback
|
||||
validate_parameters!
|
||||
setup_webhook(subscribed_fields: subscribed_fields)
|
||||
setup_webhook
|
||||
end
|
||||
|
||||
private
|
||||
@@ -55,21 +55,23 @@ class Whatsapp::WebhookSetupService
|
||||
@channel.save!
|
||||
end
|
||||
|
||||
def setup_webhook(subscribed_fields: nil)
|
||||
def setup_webhook
|
||||
callback_url = build_callback_url
|
||||
verify_token = @channel.provider_config['webhook_verify_token']
|
||||
|
||||
args = [@waba_id, callback_url, verify_token]
|
||||
if subscribed_fields
|
||||
@api_client.subscribe_waba_webhook(*args, subscribed_fields: subscribed_fields)
|
||||
else
|
||||
@api_client.subscribe_waba_webhook(*args)
|
||||
end
|
||||
@api_client.subscribe_waba_webhook(@waba_id, callback_url, verify_token, subscribed_fields: subscribed_fields)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[WHATSAPP] Webhook setup failed: #{e.message}")
|
||||
raise "Webhook setup failed: #{e.message}"
|
||||
end
|
||||
|
||||
# Subscribe to `calls` only when voice calling is enabled on the inbox
|
||||
def subscribed_fields
|
||||
fields = %w[messages smb_message_echoes]
|
||||
fields << 'calls' if @channel.provider_config['calling_enabled']
|
||||
fields
|
||||
end
|
||||
|
||||
def build_callback_url
|
||||
frontend_url = ENV.fetch('FRONTEND_URL', nil)
|
||||
phone_number = @channel.phone_number
|
||||
|
||||
@@ -5,5 +5,10 @@ json.inbox resource.inbox&.slice(:id, :name)
|
||||
json.account_id resource.account_id
|
||||
json.hook_type resource.hook_type
|
||||
|
||||
json.settings resource.settings if Current.account_user&.administrator?
|
||||
json.reference_id resource.reference_id if Current.account_user&.administrator?
|
||||
if Current.account_user&.administrator?
|
||||
visible_properties = resource.app&.visible_properties || []
|
||||
settings = (resource.settings || {}).select { |key, _| visible_properties.include?(key.to_s) }
|
||||
|
||||
json.settings settings
|
||||
json.reference_id resource.reference_id
|
||||
end
|
||||
|
||||
@@ -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
|
||||
@@ -19,7 +19,7 @@ class Rack::Attack
|
||||
Rack::Attack.cache.store = ActiveSupport::Cache::RedisCacheStore.new(redis: $velma, pool: false)
|
||||
|
||||
class Request < ::Rack::Request
|
||||
# You many need to specify a method to fetch the correct remote IP address
|
||||
# You may need to specify a method to fetch the correct remote IP address
|
||||
# if the web server is behind a load balancer.
|
||||
def remote_ip
|
||||
@remote_ip ||= (env['action_dispatch.remote_ip'] || ip).to_s
|
||||
@@ -31,9 +31,9 @@ class Rack::Attack
|
||||
(default_allowed_ips + env_allowed_ips).include?(remote_ip)
|
||||
end
|
||||
|
||||
# Rails would allow requests to paths with extentions, so lets compare against the path with extention stripped
|
||||
# Rails would allow requests to paths with extensions, so lets compare against the path with extension stripped
|
||||
# example /auth & /auth.json would both work
|
||||
def path_without_extentions
|
||||
def path_without_extensions
|
||||
path[/^[^.]+/]
|
||||
end
|
||||
end
|
||||
@@ -75,11 +75,11 @@ class Rack::Attack
|
||||
|
||||
### Prevent Brute-Force Super Admin Login Attacks ###
|
||||
throttle('super_admin_login/ip', limit: 5, period: 5.minutes) do |req|
|
||||
req.ip if req.path_without_extentions == '/super_admin/sign_in' && req.post?
|
||||
req.ip if req.path_without_extensions == '/super_admin/sign_in' && req.post?
|
||||
end
|
||||
|
||||
throttle('super_admin_login/email', limit: 5, period: 15.minutes) do |req|
|
||||
if req.path_without_extentions == '/super_admin/sign_in' && req.post?
|
||||
if req.path_without_extensions == '/super_admin/sign_in' && req.post?
|
||||
# NOTE: This line used to throw ArgumentError /rails/action_mailbox/sendgrid/inbound_emails : invalid byte sequence in UTF-8
|
||||
# Hence placed in the if block
|
||||
# ref: https://github.com/rack/rack-attack/issues/399
|
||||
@@ -91,7 +91,7 @@ class Rack::Attack
|
||||
# ### Prevent Brute-Force Login Attacks ###
|
||||
# Exclude MFA verification attempts from regular login throttling
|
||||
throttle('login/ip', limit: 5, period: 5.minutes) do |req|
|
||||
if req.path_without_extentions == '/auth/sign_in' && req.post? && req.params['mfa_token'].blank?
|
||||
if req.path_without_extensions == '/auth/sign_in' && req.post? && req.params['mfa_token'].blank?
|
||||
# Skip if this is an MFA verification request
|
||||
req.ip
|
||||
end
|
||||
@@ -99,7 +99,7 @@ class Rack::Attack
|
||||
|
||||
throttle('login/email', limit: 10, period: 15.minutes) do |req|
|
||||
# Skip if this is an MFA verification request
|
||||
if req.path_without_extentions == '/auth/sign_in' && req.post? && req.params['mfa_token'].blank?
|
||||
if req.path_without_extensions == '/auth/sign_in' && req.post? && req.params['mfa_token'].blank?
|
||||
# ref: https://github.com/rack/rack-attack/issues/399
|
||||
# NOTE: This line used to throw ArgumentError /rails/action_mailbox/sendgrid/inbound_emails : invalid byte sequence in UTF-8
|
||||
# Hence placed in the if block
|
||||
@@ -110,11 +110,11 @@ class Rack::Attack
|
||||
|
||||
## Reset password throttling
|
||||
throttle('reset_password/ip', limit: 5, period: 30.minutes) do |req|
|
||||
req.ip if req.path_without_extentions == '/auth/password' && req.post?
|
||||
req.ip if req.path_without_extensions == '/auth/password' && req.post?
|
||||
end
|
||||
|
||||
throttle('reset_password/email', limit: 5, period: 1.hour) do |req|
|
||||
if req.path_without_extentions == '/auth/password' && req.post?
|
||||
if req.path_without_extensions == '/auth/password' && req.post?
|
||||
email = req.params['email'].presence || ActionDispatch::Request.new(req.env).params['email'].presence
|
||||
email.to_s.downcase.gsub(/\s+/, '')
|
||||
end
|
||||
@@ -122,11 +122,11 @@ class Rack::Attack
|
||||
|
||||
## Resend confirmation throttling (unauthenticated)
|
||||
throttle('resend_confirmation/ip', limit: 5, period: 30.minutes) do |req|
|
||||
req.ip if req.path_without_extentions == '/resend_confirmation' && req.post?
|
||||
req.ip if req.path_without_extensions == '/resend_confirmation' && req.post?
|
||||
end
|
||||
|
||||
throttle('resend_confirmation/email', limit: 5, period: 1.hour) do |req|
|
||||
if req.path_without_extentions == '/resend_confirmation' && req.post?
|
||||
if req.path_without_extensions == '/resend_confirmation' && req.post?
|
||||
email = req.params['email'].presence || ActionDispatch::Request.new(req.env).params['email'].presence
|
||||
email.to_s.downcase.gsub(/\s+/, '')
|
||||
end
|
||||
@@ -134,25 +134,25 @@ class Rack::Attack
|
||||
|
||||
## Resend confirmation throttling (authenticated)
|
||||
throttle('resend_confirmation_auth/ip', limit: 5, period: 30.minutes) do |req|
|
||||
req.ip if req.path_without_extentions == '/api/v1/profile/resend_confirmation' && req.post?
|
||||
req.ip if req.path_without_extensions == '/api/v1/profile/resend_confirmation' && req.post?
|
||||
end
|
||||
|
||||
## MFA throttling - prevent brute force attacks
|
||||
throttle('mfa_verification/ip', limit: 5, period: 1.minute) do |req|
|
||||
if req.path_without_extentions == '/api/v1/profile/mfa'
|
||||
if req.path_without_extensions == '/api/v1/profile/mfa'
|
||||
req.ip if req.delete? # Throttle disable attempts
|
||||
elsif req.path_without_extentions.match?(%r{/api/v1/profile/mfa/(verify|backup_codes)})
|
||||
elsif req.path_without_extensions.match?(%r{/api/v1/profile/mfa/(verify|backup_codes)})
|
||||
req.ip if req.post? # Throttle verify and backup_codes attempts
|
||||
end
|
||||
end
|
||||
|
||||
# Separate rate limiting for MFA verification attempts
|
||||
throttle('mfa_login/ip', limit: 10, period: 1.minute) do |req|
|
||||
req.ip if req.path_without_extentions == '/auth/sign_in' && req.post? && req.params['mfa_token'].present?
|
||||
req.ip if req.path_without_extensions == '/auth/sign_in' && req.post? && req.params['mfa_token'].present?
|
||||
end
|
||||
|
||||
throttle('mfa_login/token', limit: 10, period: 1.minute) do |req|
|
||||
if req.path_without_extentions == '/auth/sign_in' && req.post?
|
||||
if req.path_without_extensions == '/auth/sign_in' && req.post?
|
||||
# Track by MFA token to prevent brute force on a specific token
|
||||
mfa_token = req.params['mfa_token'].presence
|
||||
(mfa_token.presence)
|
||||
@@ -161,7 +161,7 @@ class Rack::Attack
|
||||
|
||||
## Prevent Brute-Force Signup Attacks ###
|
||||
throttle('accounts/ip', limit: 5, period: 30.minutes) do |req|
|
||||
req.ip if req.path_without_extentions == '/api/v1/accounts' && req.post?
|
||||
req.ip if req.path_without_extensions == '/api/v1/accounts' && req.post?
|
||||
end
|
||||
|
||||
##-----------------------------------------------##
|
||||
@@ -176,17 +176,17 @@ class Rack::Attack
|
||||
if ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_RACK_ATTACK_WIDGET_API', true))
|
||||
## Prevent Conversation Bombing on Widget APIs ###
|
||||
throttle('api/v1/widget/conversations', limit: 6, period: 12.hours) do |req|
|
||||
req.ip if req.path_without_extentions == '/api/v1/widget/conversations' && req.post?
|
||||
req.ip if req.path_without_extensions == '/api/v1/widget/conversations' && req.post?
|
||||
end
|
||||
|
||||
## Prevent Contact update Bombing in Widget API ###
|
||||
throttle('api/v1/widget/contacts', limit: 60, period: 1.hour) do |req|
|
||||
req.ip if req.path_without_extentions == '/api/v1/widget/contacts' && (req.patch? || req.put?)
|
||||
req.ip if req.path_without_extensions == '/api/v1/widget/contacts' && (req.patch? || req.put?)
|
||||
end
|
||||
|
||||
## Prevent Conversation Bombing through multiple sessions
|
||||
throttle('widget?website_token={website_token}&cw_conversation={x-auth-token}', limit: 5, period: 1.hour) do |req|
|
||||
req.ip if req.path_without_extentions == '/widget' && ActionDispatch::Request.new(req.env).params['cw_conversation'].blank?
|
||||
req.ip if req.path_without_extensions == '/widget' && ActionDispatch::Request.new(req.env).params['cw_conversation'].blank?
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
# hook_type: ( account / inbox )
|
||||
# feature_flag: (string) feature flag to enable/disable the integration
|
||||
# allow_multiple_hooks: whether multiple hooks can be created for the integration
|
||||
# visible_properties: hook setting keys safe to return in API responses and show in the UI
|
||||
# settings_json_schema: the json schema used to validate the settings hash (https://json-schema.org/)
|
||||
# settings_form_schema: the formulate schema used in frontend to render settings form (https://vueformulate.com/)
|
||||
########################################################
|
||||
@@ -55,7 +56,7 @@ openai:
|
||||
'validation': '',
|
||||
},
|
||||
]
|
||||
visible_properties: ['api_key', 'label_suggestion']
|
||||
visible_properties: ['label_suggestion']
|
||||
linear:
|
||||
id: linear
|
||||
logo: linear.png
|
||||
@@ -63,12 +64,14 @@ linear:
|
||||
action: https://linear.app/oauth/authorize
|
||||
hook_type: account
|
||||
allow_multiple_hooks: false
|
||||
visible_properties: []
|
||||
notion:
|
||||
id: notion
|
||||
logo: notion.png
|
||||
i18n_key: notion
|
||||
hook_type: account
|
||||
allow_multiple_hooks: false
|
||||
visible_properties: []
|
||||
slack:
|
||||
id: slack
|
||||
logo: slack.png
|
||||
@@ -76,6 +79,7 @@ slack:
|
||||
action: https://slack.com/oauth/v2/authorize?scope=commands,chat:write,channels:read,channels:manage,channels:join,groups:read,groups:write,im:write,mpim:write,users:read,users:read.email,chat:write.customize,channels:history,groups:history,mpim:history,im:history,files:read,files:write
|
||||
hook_type: account
|
||||
allow_multiple_hooks: false
|
||||
visible_properties: ['channel_name']
|
||||
dialogflow:
|
||||
id: dialogflow
|
||||
logo: dialogflow.png
|
||||
@@ -240,6 +244,7 @@ shopify:
|
||||
i18n_key: shopify
|
||||
hook_type: account
|
||||
allow_multiple_hooks: false
|
||||
visible_properties: []
|
||||
|
||||
leadsquared:
|
||||
id: leadsquared
|
||||
@@ -310,7 +315,6 @@ leadsquared:
|
||||
]
|
||||
visible_properties:
|
||||
[
|
||||
'access_key',
|
||||
'endpoint_url',
|
||||
'enable_conversation_activity',
|
||||
'enable_transcript_activity',
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -437,6 +437,7 @@ Rails.application.routes.draw do
|
||||
post :verify
|
||||
post :backup_codes
|
||||
end
|
||||
resources :sessions, only: [:index, :destroy]
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -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
|
||||
+22
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_06_04_000000) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_06_11_184600) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -1253,6 +1253,26 @@ ActiveRecord::Schema[7.1].define(version: 2026_06_04_000000) 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
|
||||
@@ -1325,6 +1345,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_06_04_000000) 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).
|
||||
|
||||
@@ -42,7 +42,7 @@ RSpec.describe 'Integration Apps API', type: :request do
|
||||
expect(app['hooks'].first['settings']).to be_nil
|
||||
end
|
||||
|
||||
it 'returns all active apps with sensitive information if user is an admin' do
|
||||
it 'returns all active apps with admin metadata if user is an admin' do
|
||||
first_app = Integrations::App.all.find { |app| app.active?(account) }
|
||||
get api_v1_account_integrations_apps_url(account),
|
||||
headers: admin.create_new_auth_token,
|
||||
@@ -56,19 +56,21 @@ RSpec.describe 'Integration Apps API', type: :request do
|
||||
end
|
||||
|
||||
it 'returns slack app with appropriate redirect url when configured' do
|
||||
with_modified_env SLACK_CLIENT_ID: 'client_id', SLACK_CLIENT_SECRET: 'client_secret' do
|
||||
get api_v1_account_integrations_apps_url(account),
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
allow(GlobalConfigService).to receive(:load).and_call_original
|
||||
allow(GlobalConfigService).to receive(:load).with('SLACK_CLIENT_ID', nil).and_return('client_id')
|
||||
allow(GlobalConfigService).to receive(:load).with('SLACK_CLIENT_SECRET', nil).and_return('client_secret')
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
apps = response.parsed_body['payload']
|
||||
slack_app = apps.find { |app| app['id'] == 'slack' }
|
||||
expect(slack_app['action']).to include('client_id=client_id')
|
||||
end
|
||||
get api_v1_account_integrations_apps_url(account),
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
apps = response.parsed_body['payload']
|
||||
slack_app = apps.find { |app| app['id'] == 'slack' }
|
||||
expect(slack_app['action']).to include('client_id=client_id')
|
||||
end
|
||||
|
||||
it 'will return sensitive information for openai app for admins' do
|
||||
it 'returns visible hook settings for openai app for admins' do
|
||||
openai = create(:integrations_hook, :openai, account: account)
|
||||
get api_v1_account_integrations_apps_url(account),
|
||||
headers: admin.create_new_auth_token,
|
||||
@@ -79,6 +81,34 @@ RSpec.describe 'Integration Apps API', type: :request do
|
||||
app = response.parsed_body['payload'].find { |int_app| int_app['id'] == openai.app.id }
|
||||
expect(app['hooks'].first['settings']).not_to be_nil
|
||||
end
|
||||
|
||||
it 'redacts secrets and only returns visible settings for openai hooks' do
|
||||
openai = create(
|
||||
:integrations_hook,
|
||||
:openai,
|
||||
account: account,
|
||||
settings: { api_key: 'sk-secret', label_suggestion: true }
|
||||
)
|
||||
get api_v1_account_integrations_apps_url(account),
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
app = response.parsed_body['payload'].find { |int_app| int_app['id'] == openai.app.id }
|
||||
expect(app['hooks'].first['settings']).to eq('label_suggestion' => true)
|
||||
end
|
||||
|
||||
it 'keeps slack channel display settings while redacting unspecified settings' do
|
||||
create(:integrations_hook, account: account, settings: { channel_name: 'support', signing_secret: 'secret' })
|
||||
allow(GlobalConfigService).to receive(:load).and_call_original
|
||||
allow(GlobalConfigService).to receive(:load).with('SLACK_CLIENT_SECRET', nil).and_return('client_secret')
|
||||
|
||||
get api_v1_account_integrations_apps_url(account),
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
app = response.parsed_body['payload'].find { |int_app| int_app['id'] == 'slack' }
|
||||
expect(app['hooks'].first['settings']).to eq('channel_name' => 'support')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -117,7 +147,7 @@ RSpec.describe 'Integration Apps API', type: :request do
|
||||
expect(app['hooks'].first['settings']).to be_nil
|
||||
end
|
||||
|
||||
it 'will return sensitive information for openai app for admins' do
|
||||
it 'returns visible hook settings for openai app for admins' do
|
||||
openai = create(:integrations_hook, :openai, account: account)
|
||||
get api_v1_account_integrations_app_url(account_id: account.id, id: openai.app.id),
|
||||
headers: admin.create_new_auth_token,
|
||||
@@ -128,6 +158,53 @@ RSpec.describe 'Integration Apps API', type: :request do
|
||||
app = response.parsed_body
|
||||
expect(app['hooks'].first['settings']).not_to be_nil
|
||||
end
|
||||
|
||||
it 'hides credentials and keeps visible settings for google credential integrations' do
|
||||
hook = create(:integrations_hook, :google_translate, account: account,
|
||||
settings: { project_id: 'project-1',
|
||||
credentials: { private_key: 'secret' } })
|
||||
get api_v1_account_integrations_app_url(account_id: account.id, id: hook.app.id),
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
app = response.parsed_body
|
||||
expect(app['hooks'].first['settings']).to eq('project_id' => 'project-1')
|
||||
end
|
||||
|
||||
it 'returns empty settings for oauth integrations with no visible properties' do
|
||||
hook = create(
|
||||
:integrations_hook,
|
||||
:linear,
|
||||
account: account,
|
||||
settings: { token_type: 'Bearer', refresh_token: 'refresh-secret', expires_in: 7200 }
|
||||
)
|
||||
get api_v1_account_integrations_app_url(account_id: account.id, id: hook.app.id),
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
app = response.parsed_body
|
||||
expect(app['hooks'].first['settings']).to eq({})
|
||||
end
|
||||
|
||||
it 'does not expose leadsquared credential keys in visible settings' do
|
||||
account.enable_features('crm_integration')
|
||||
hook = create(:integrations_hook, :leadsquared, account: account,
|
||||
settings: {
|
||||
'access_key' => 'access-secret',
|
||||
'secret_key' => 'secret',
|
||||
'endpoint_url' => 'https://api.leadsquared.com/',
|
||||
'enable_conversation_activity' => true
|
||||
})
|
||||
get api_v1_account_integrations_app_url(account_id: account.id, id: hook.app.id),
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
settings = response.parsed_body['hooks'].first['settings']
|
||||
expect(settings).to eq(
|
||||
'endpoint_url' => 'https://api.leadsquared.com/',
|
||||
'enable_conversation_activity' => true
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -163,4 +163,21 @@ RSpec.describe DeviseOverrides::SessionsController, type: :controller do
|
||||
expect(response).to redirect_to('/frontend/app/login?error=access-denied')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'session tracking' do
|
||||
let(:user) { create(:user, password: 'Test@123456') }
|
||||
let(:browser_ua) { 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15' }
|
||||
|
||||
context 'with a successful login' do
|
||||
before { request.env['HTTP_USER_AGENT'] = browser_ua }
|
||||
|
||||
it 'creates a UserSession row for the new client_id' do
|
||||
expect { post :create, params: { email: user.email, password: 'Test@123456' } }.to change(user.user_sessions, :count).by(1)
|
||||
|
||||
session = user.user_sessions.last
|
||||
expect(session.browser_name).to eq('Safari')
|
||||
expect(session.platform_name).to eq('macOS')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -12,7 +12,10 @@ RSpec.describe Onboarding::HelpCenterArticleBuilder do
|
||||
|
||||
expect(Firecrawl::Configuration).not_to receive(:client)
|
||||
expect { builder.perform }
|
||||
.to raise_error(Onboarding::HelpCenterErrors::ArticleBuildFailed, /no source urls/)
|
||||
.to raise_error(StandardError) { |error|
|
||||
expect(error.class.name).to eq('Onboarding::HelpCenterErrors::ArticleBuildFailed')
|
||||
expect(error.message).to include('no source urls')
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe UserSessionIpLookupJob do
|
||||
let(:user) { create(:user) }
|
||||
let(:session) { user.user_sessions.create!(client_id: 'c', ip_address: '8.8.8.8', last_activity_at: Time.current) }
|
||||
let(:geo_result) { OpenStruct.new(city: 'Mountain View', country: 'United States', country_code: 'US') }
|
||||
let(:ip_lookup) { instance_double(IpLookupService) }
|
||||
|
||||
before { allow(IpLookupService).to receive(:new).and_return(ip_lookup) }
|
||||
|
||||
it 'backfills geo data on the session' do
|
||||
allow(ip_lookup).to receive(:perform).with('8.8.8.8').and_return(geo_result)
|
||||
|
||||
described_class.perform_now(session)
|
||||
|
||||
session.reload
|
||||
expect(session.city).to eq('Mountain View')
|
||||
expect(session.country).to eq('United States')
|
||||
expect(session.country_code).to eq('US')
|
||||
end
|
||||
|
||||
it 'is a no-op when ip_address is blank' do
|
||||
session.update_columns(ip_address: nil) # rubocop:disable Rails/SkipsModelValidations
|
||||
|
||||
described_class.perform_now(session)
|
||||
|
||||
expect(IpLookupService).not_to have_received(:new)
|
||||
end
|
||||
|
||||
it 'leaves the session untouched when lookup returns nil' do
|
||||
allow(ip_lookup).to receive(:perform).and_return(nil)
|
||||
|
||||
described_class.perform_now(session)
|
||||
|
||||
session.reload
|
||||
expect(session.city).to be_nil
|
||||
expect(session.country).to be_nil
|
||||
end
|
||||
|
||||
it 'swallows lookup errors so a flaky geocoder does not poison the queue' do
|
||||
allow(ip_lookup).to receive(:perform).and_raise(StandardError.new('boom'))
|
||||
|
||||
expect { described_class.perform_now(session) }.not_to raise_error
|
||||
end
|
||||
end
|
||||
@@ -23,6 +23,24 @@ RSpec.describe Integrations::App do
|
||||
end
|
||||
end
|
||||
|
||||
describe '#visible_properties' do
|
||||
context 'when the app has visible properties' do
|
||||
let(:app_name) { 'dialogflow' }
|
||||
|
||||
it 'returns the configured property names as strings' do
|
||||
expect(app.visible_properties).to contain_exactly('project_id', 'region', 'language_code')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the app has no visible properties configured' do
|
||||
let(:app_name) { 'webhook' }
|
||||
|
||||
it 'defaults to an empty list' do
|
||||
expect(app.visible_properties).to eq([])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#action' do
|
||||
let(:app_name) { 'slack' }
|
||||
|
||||
@@ -32,12 +50,13 @@ RSpec.describe Integrations::App do
|
||||
|
||||
context 'when the app is slack' do
|
||||
it 'returns the action URL with client_id and redirect_uri' do
|
||||
with_modified_env SLACK_CLIENT_ID: 'dummy_client_id' do
|
||||
expect(app.action).to include('client_id=dummy_client_id')
|
||||
expect(app.action).to include(
|
||||
"/app/accounts/#{account.id}/settings/integrations/slack"
|
||||
)
|
||||
end
|
||||
allow(GlobalConfigService).to receive(:load).and_call_original
|
||||
allow(GlobalConfigService).to receive(:load).with('SLACK_CLIENT_ID', nil).and_return('dummy_client_id')
|
||||
|
||||
expect(app.action).to include('client_id=dummy_client_id')
|
||||
expect(app.action).to include(
|
||||
"/app/accounts/#{account.id}/settings/integrations/slack"
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -47,9 +66,9 @@ RSpec.describe Integrations::App do
|
||||
|
||||
context 'when the app is slack' do
|
||||
it 'returns true if SLACK_CLIENT_SECRET is present' do
|
||||
with_modified_env SLACK_CLIENT_SECRET: 'random_secret' do
|
||||
expect(app.active?(account)).to be true
|
||||
end
|
||||
allow(GlobalConfigService).to receive(:load).with('SLACK_CLIENT_SECRET', nil).and_return('random_secret')
|
||||
|
||||
expect(app.active?(account)).to be true
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe UserSession do
|
||||
let(:user) { create(:user) }
|
||||
|
||||
describe 'associations' do
|
||||
it { is_expected.to belong_to(:user) }
|
||||
end
|
||||
|
||||
describe 'validations' do
|
||||
subject { described_class.new(user: user, client_id: 'abc') }
|
||||
|
||||
it { is_expected.to validate_presence_of(:client_id) }
|
||||
|
||||
it 'validates uniqueness of client_id scoped to user_id' do
|
||||
described_class.create!(user: user, client_id: 'abc', last_activity_at: Time.current)
|
||||
|
||||
duplicate = described_class.new(user: user, client_id: 'abc')
|
||||
expect(duplicate).not_to be_valid
|
||||
expect(duplicate.errors[:client_id]).to be_present
|
||||
end
|
||||
|
||||
it 'allows the same client_id for different users' do
|
||||
other = create(:user)
|
||||
described_class.create!(user: user, client_id: 'abc', last_activity_at: Time.current)
|
||||
|
||||
expect(described_class.new(user: other, client_id: 'abc', last_activity_at: Time.current)).to be_valid
|
||||
end
|
||||
end
|
||||
|
||||
describe '#current?' do
|
||||
let(:session) { described_class.create!(user: user, client_id: 'abc', last_activity_at: Time.current) }
|
||||
|
||||
it 'returns true when client_id matches' do
|
||||
expect(session.current?('abc')).to be true
|
||||
end
|
||||
|
||||
it 'returns false when client_id differs' do
|
||||
expect(session.current?('xyz')).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe '#should_update_activity?' do
|
||||
let(:session) { described_class.new(user: user, client_id: 'abc') }
|
||||
|
||||
it 'returns true when last_activity_at is nil' do
|
||||
session.last_activity_at = nil
|
||||
expect(session.should_update_activity?).to be true
|
||||
end
|
||||
|
||||
it 'returns true when last_activity_at is older than the throttle window' do
|
||||
session.last_activity_at = 10.minutes.ago
|
||||
expect(session.should_update_activity?).to be true
|
||||
end
|
||||
|
||||
it 'returns false when last_activity_at is within the throttle window' do
|
||||
session.last_activity_at = 1.minute.ago
|
||||
expect(session.should_update_activity?).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -254,4 +254,37 @@ RSpec.describe User do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'sync_user_sessions callback' do
|
||||
let(:user_with_tokens) do
|
||||
u = create(:user)
|
||||
u.tokens = {
|
||||
'client-a' => { 'token' => 'x', 'expiry' => 1.month.from_now.to_i },
|
||||
'client-b' => { 'token' => 'x', 'expiry' => 1.month.from_now.to_i }
|
||||
}
|
||||
u.save!
|
||||
u.user_sessions.create!(client_id: 'client-a', last_activity_at: Time.current)
|
||||
u.user_sessions.create!(client_id: 'client-b', last_activity_at: Time.current)
|
||||
u
|
||||
end
|
||||
|
||||
it 'destroys user_sessions whose client_id is no longer in tokens' do
|
||||
user_with_tokens.tokens = user_with_tokens.tokens.except('client-a')
|
||||
|
||||
expect { user_with_tokens.save! }.to change(user_with_tokens.user_sessions, :count).by(-1)
|
||||
expect(user_with_tokens.user_sessions.pluck(:client_id)).to eq(['client-b'])
|
||||
end
|
||||
|
||||
it 'leaves user_sessions alone when tokens did not change' do
|
||||
user_with_tokens.update!(name: 'New Name')
|
||||
|
||||
expect(user_with_tokens.user_sessions.count).to eq(2)
|
||||
end
|
||||
|
||||
it 'destroys all user_sessions when tokens is cleared' do
|
||||
user_with_tokens.tokens = {}
|
||||
|
||||
expect { user_with_tokens.save! }.to change(user_with_tokens.user_sessions, :count).by(-2)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Profile Sessions API', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:auth_headers) { user.create_new_auth_token }
|
||||
let(:current_client_id) { auth_headers['client'] }
|
||||
|
||||
describe 'GET /api/v1/profile/sessions' do
|
||||
it 'returns 401 without auth' do
|
||||
get '/api/v1/profile/sessions', as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it 'returns the current user sessions ordered by last_activity_at desc' do
|
||||
older = user.user_sessions.create!(client_id: current_client_id, browser_name: 'Chrome', last_activity_at: 2.days.ago)
|
||||
newer = user.user_sessions.create!(client_id: 'other-client', browser_name: 'Firefox', last_activity_at: 1.hour.ago)
|
||||
user.update!(tokens: user.tokens.merge('other-client' => { 'token' => 'x', 'expiry' => 1.month.from_now.to_i }))
|
||||
|
||||
get '/api/v1/profile/sessions', headers: auth_headers, as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
sessions = response.parsed_body
|
||||
expect(sessions.map { |s| s['id'] }).to eq([newer.id, older.id])
|
||||
expect(sessions.find { |s| s['id'] == older.id }['current']).to be true
|
||||
expect(sessions.find { |s| s['id'] == newer.id }['current']).to be false
|
||||
end
|
||||
|
||||
it 'excludes sessions whose token has expired' do
|
||||
live = user.user_sessions.create!(client_id: current_client_id, last_activity_at: 1.hour.ago)
|
||||
expired = user.user_sessions.create!(client_id: 'expired-client', last_activity_at: 1.day.ago)
|
||||
user.update!(tokens: user.tokens.merge('expired-client' => { 'token' => 'x', 'expiry' => 1.day.ago.to_i }))
|
||||
|
||||
get '/api/v1/profile/sessions', headers: auth_headers, as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
ids = response.parsed_body.map { |s| s['id'] }
|
||||
expect(ids).to include(live.id)
|
||||
expect(ids).not_to include(expired.id)
|
||||
end
|
||||
|
||||
it 'returns an empty array when no sessions exist' do
|
||||
get '/api/v1/profile/sessions', headers: auth_headers, as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body).to eq([])
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE /api/v1/profile/sessions/:id' do
|
||||
let!(:other_session) { user.user_sessions.create!(client_id: 'other-client', last_activity_at: 1.hour.ago) }
|
||||
|
||||
before do
|
||||
# Seed tokens hash so revoke can clean it up
|
||||
user.tokens = user.tokens.merge('other-client' => { 'token' => 'x', 'expiry' => 1.month.from_now.to_i })
|
||||
user.save!
|
||||
end
|
||||
|
||||
it 'destroys the session and removes its token entry' do
|
||||
expect do
|
||||
delete "/api/v1/profile/sessions/#{other_session.id}", headers: auth_headers, as: :json
|
||||
end.to change(user.user_sessions, :count).by(-1)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(user.reload.tokens.keys).not_to include('other-client')
|
||||
end
|
||||
|
||||
it 'returns 422 when trying to revoke the current session' do
|
||||
current = user.user_sessions.create!(client_id: current_client_id, last_activity_at: Time.current)
|
||||
|
||||
delete "/api/v1/profile/sessions/#{current.id}", headers: auth_headers, as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to be_present
|
||||
expect(user.user_sessions.exists?(id: current.id)).to be true
|
||||
end
|
||||
|
||||
it 'returns 404 for a nonexistent session id' do
|
||||
delete '/api/v1/profile/sessions/9999999', headers: auth_headers, as: :json
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
|
||||
it 'does not allow revoking another user' do
|
||||
other_user = create(:user, account: account)
|
||||
foreign = other_user.user_sessions.create!(client_id: 'foreign', last_activity_at: 1.hour.ago)
|
||||
|
||||
delete "/api/v1/profile/sessions/#{foreign.id}", headers: auth_headers, as: :json
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
expect(other_user.user_sessions.exists?(id: foreign.id)).to be true
|
||||
end
|
||||
|
||||
it 'returns 401 without auth' do
|
||||
delete "/api/v1/profile/sessions/#{other_session.id}", as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,82 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe UserSessionTrackingService do
|
||||
let(:user) { create(:user) }
|
||||
let(:client_id) { 'client-abc' }
|
||||
let(:request) do
|
||||
instance_double(
|
||||
ActionDispatch::Request,
|
||||
user_agent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15',
|
||||
remote_ip: '8.8.8.8'
|
||||
)
|
||||
end
|
||||
let(:service) { described_class.new(user: user, request: request, client_id: client_id) }
|
||||
|
||||
describe '#create_or_update!' do
|
||||
it 'creates a new UserSession with the right client_id and timestamps' do
|
||||
expect { service.create_or_update! }.to change(user.user_sessions, :count).by(1)
|
||||
|
||||
session = user.user_sessions.last
|
||||
expect(session.client_id).to eq(client_id)
|
||||
expect(session.last_activity_at).to be_within(1.second).of(Time.current)
|
||||
end
|
||||
|
||||
it 'populates request and browser metadata synchronously', :aggregate_failures do
|
||||
service.create_or_update!
|
||||
|
||||
session = user.user_sessions.last
|
||||
expect(session.ip_address).to eq('8.8.8.8')
|
||||
expect(session.browser_name).to eq('Safari')
|
||||
expect(session.platform_name).to eq('macOS')
|
||||
end
|
||||
|
||||
it 'does not call IpLookupService synchronously' do
|
||||
expect(IpLookupService).not_to receive(:new)
|
||||
|
||||
service.create_or_update!
|
||||
end
|
||||
|
||||
it 'enqueues UserSessionIpLookupJob to backfill geo data' do
|
||||
expect { service.create_or_update! }.to have_enqueued_job(UserSessionIpLookupJob)
|
||||
end
|
||||
|
||||
it 'updates an existing session when client_id matches' do
|
||||
existing = user.user_sessions.create!(client_id: client_id, ip_address: '1.1.1.1', last_activity_at: 1.day.ago)
|
||||
|
||||
expect { service.create_or_update! }.not_to change(user.user_sessions, :count)
|
||||
expect(existing.reload.ip_address).to eq('8.8.8.8')
|
||||
expect(existing.last_activity_at).to be_within(1.second).of(Time.current)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#update_activity!' do
|
||||
it 'does nothing when no session exists for the client_id' do
|
||||
expect { service.update_activity! }.not_to change(user.user_sessions, :count)
|
||||
end
|
||||
|
||||
it 'does nothing when the session was recently active' do
|
||||
session = user.user_sessions.create!(client_id: client_id, last_activity_at: 1.minute.ago)
|
||||
before_ts = session.last_activity_at
|
||||
|
||||
service.update_activity!
|
||||
|
||||
expect(session.reload.last_activity_at).to be_within(1.second).of(before_ts)
|
||||
end
|
||||
|
||||
it 'bumps last_activity_at when the session is stale' do
|
||||
session = user.user_sessions.create!(client_id: client_id, last_activity_at: 10.minutes.ago)
|
||||
|
||||
service.update_activity!
|
||||
|
||||
expect(session.reload.last_activity_at).to be_within(1.second).of(Time.current)
|
||||
end
|
||||
|
||||
it 'bumps last_activity_at when last_activity_at is nil' do
|
||||
session = user.user_sessions.create!(client_id: client_id, last_activity_at: nil)
|
||||
|
||||
service.update_activity!
|
||||
|
||||
expect(session.reload.last_activity_at).to be_within(1.second).of(Time.current)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -177,7 +177,7 @@ describe Whatsapp::FacebookApiClient do
|
||||
.with(
|
||||
headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' },
|
||||
body: { override_callback_uri: callback_url, verify_token: verify_token,
|
||||
subscribed_fields: %w[messages smb_message_echoes calls] }.to_json
|
||||
subscribed_fields: %w[messages smb_message_echoes] }.to_json
|
||||
)
|
||||
.to_return(
|
||||
status: 200,
|
||||
@@ -224,7 +224,7 @@ describe Whatsapp::FacebookApiClient do
|
||||
.with(
|
||||
headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' },
|
||||
body: { override_callback_uri: callback_url, verify_token: verify_token,
|
||||
subscribed_fields: %w[messages smb_message_echoes calls] }.to_json
|
||||
subscribed_fields: %w[messages smb_message_echoes] }.to_json
|
||||
)
|
||||
.to_return(status: 400, body: { error: 'Webhook callback override failed' }.to_json)
|
||||
end
|
||||
|
||||
@@ -59,6 +59,41 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when document attachment includes an accented filename' do
|
||||
let(:document_params) do
|
||||
{
|
||||
phone_number: whatsapp_channel.phone_number,
|
||||
object: 'whatsapp_business_account',
|
||||
entry: [{
|
||||
changes: [{
|
||||
value: {
|
||||
contacts: [{ profile: { name: 'Sojan Jose' }, wa_id: '2423423243' }],
|
||||
messages: [{
|
||||
from: '2423423243',
|
||||
document: {
|
||||
id: 'b1c68f38-8734-4ad3-b4a1-ef0c10d683',
|
||||
mime_type: 'application/pdf',
|
||||
filename: 'Currículum café.pdf',
|
||||
caption: 'My résumé'
|
||||
},
|
||||
timestamp: '1664799904', type: 'document'
|
||||
}]
|
||||
}
|
||||
}]
|
||||
}]
|
||||
}.with_indifferent_access
|
||||
end
|
||||
|
||||
it 'preserves the original filename from the payload' do
|
||||
stub_media_url_request
|
||||
stub_sample_png_request
|
||||
described_class.new(inbox: whatsapp_channel.inbox, params: document_params).perform
|
||||
|
||||
attachment = whatsapp_channel.inbox.messages.first.attachments.first
|
||||
expect(attachment.file.filename.to_s).to eq('Currículum café.pdf')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when invalid attachment message params' do
|
||||
let(:error_params) do
|
||||
{
|
||||
|
||||
@@ -43,7 +43,7 @@ describe Whatsapp::WebhookSetupService do
|
||||
allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456)
|
||||
allow(api_client).to receive(:register_phone_number).with('123456789', 223_456)
|
||||
allow(api_client).to receive(:subscribe_waba_webhook)
|
||||
.with(waba_id, anything, 'test_verify_token').and_return({ 'success' => true })
|
||||
.with(waba_id, anything, 'test_verify_token', subscribed_fields: %w[messages smb_message_echoes]).and_return({ 'success' => true })
|
||||
allow(channel).to receive(:save!)
|
||||
end
|
||||
|
||||
@@ -51,7 +51,8 @@ describe Whatsapp::WebhookSetupService do
|
||||
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
|
||||
expect(api_client).to receive(:register_phone_number).with('123456789', 223_456)
|
||||
expect(api_client).to receive(:subscribe_waba_webhook)
|
||||
.with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token')
|
||||
.with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token', subscribed_fields: %w[messages
|
||||
smb_message_echoes])
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
@@ -65,14 +66,15 @@ describe Whatsapp::WebhookSetupService do
|
||||
throughput: { level: 'APPLICABLE' }
|
||||
})
|
||||
allow(api_client).to receive(:subscribe_waba_webhook)
|
||||
.with(waba_id, anything, 'test_verify_token').and_return({ 'success' => true })
|
||||
.with(waba_id, anything, 'test_verify_token', subscribed_fields: %w[messages smb_message_echoes]).and_return({ 'success' => true })
|
||||
end
|
||||
|
||||
it 'does NOT register phone, but sets up webhook' do
|
||||
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
|
||||
expect(api_client).not_to receive(:register_phone_number)
|
||||
expect(api_client).to receive(:subscribe_waba_webhook)
|
||||
.with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token')
|
||||
.with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token', subscribed_fields: %w[messages
|
||||
smb_message_echoes])
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
@@ -88,7 +90,7 @@ describe Whatsapp::WebhookSetupService do
|
||||
allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456)
|
||||
allow(api_client).to receive(:register_phone_number).with('123456789', 223_456)
|
||||
allow(api_client).to receive(:subscribe_waba_webhook)
|
||||
.with(waba_id, anything, 'test_verify_token').and_return({ 'success' => true })
|
||||
.with(waba_id, anything, 'test_verify_token', subscribed_fields: %w[messages smb_message_echoes]).and_return({ 'success' => true })
|
||||
allow(channel).to receive(:save!)
|
||||
end
|
||||
|
||||
@@ -96,7 +98,8 @@ describe Whatsapp::WebhookSetupService do
|
||||
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
|
||||
expect(api_client).to receive(:register_phone_number).with('123456789', 223_456)
|
||||
expect(api_client).to receive(:subscribe_waba_webhook)
|
||||
.with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token')
|
||||
.with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token', subscribed_fields: %w[messages
|
||||
smb_message_echoes])
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
@@ -112,7 +115,7 @@ describe Whatsapp::WebhookSetupService do
|
||||
allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456)
|
||||
allow(api_client).to receive(:register_phone_number).with('123456789', 223_456)
|
||||
allow(api_client).to receive(:subscribe_waba_webhook)
|
||||
.with(waba_id, anything, 'test_verify_token').and_return({ 'success' => true })
|
||||
.with(waba_id, anything, 'test_verify_token', subscribed_fields: %w[messages smb_message_echoes]).and_return({ 'success' => true })
|
||||
allow(channel).to receive(:save!)
|
||||
end
|
||||
|
||||
@@ -120,7 +123,8 @@ describe Whatsapp::WebhookSetupService do
|
||||
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
|
||||
expect(api_client).to receive(:register_phone_number).with('123456789', 223_456)
|
||||
expect(api_client).to receive(:subscribe_waba_webhook)
|
||||
.with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token')
|
||||
.with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token', subscribed_fields: %w[messages
|
||||
smb_message_echoes])
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
@@ -279,14 +283,15 @@ describe Whatsapp::WebhookSetupService do
|
||||
throughput: { level: 'APPLICABLE' }
|
||||
})
|
||||
allow(api_client).to receive(:subscribe_waba_webhook)
|
||||
.with(waba_id, anything, 'existing_verify_token').and_return({ 'success' => true })
|
||||
.with(waba_id, anything, 'existing_verify_token', subscribed_fields: %w[messages smb_message_echoes]).and_return({ 'success' => true })
|
||||
end
|
||||
|
||||
it 'successfully reauthorizes with new access token' do
|
||||
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
|
||||
expect(api_client).not_to receive(:register_phone_number)
|
||||
expect(api_client).to receive(:subscribe_waba_webhook)
|
||||
.with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'existing_verify_token')
|
||||
.with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'existing_verify_token',
|
||||
subscribed_fields: %w[messages smb_message_echoes])
|
||||
service_reauth.perform
|
||||
end
|
||||
end
|
||||
@@ -294,7 +299,7 @@ describe Whatsapp::WebhookSetupService do
|
||||
it 'uses the existing webhook verify token during reauthorization' do
|
||||
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
|
||||
expect(api_client).to receive(:subscribe_waba_webhook)
|
||||
.with(waba_id, anything, 'existing_verify_token')
|
||||
.with(waba_id, anything, 'existing_verify_token', subscribed_fields: %w[messages smb_message_echoes])
|
||||
service_reauth.perform
|
||||
end
|
||||
end
|
||||
@@ -308,7 +313,7 @@ describe Whatsapp::WebhookSetupService do
|
||||
throughput: { level: 'APPLICABLE' }
|
||||
})
|
||||
allow(api_client).to receive(:subscribe_waba_webhook)
|
||||
.with(waba_id, anything, 'test_verify_token').and_return({ 'success' => true })
|
||||
.with(waba_id, anything, 'test_verify_token', subscribed_fields: %w[messages smb_message_echoes]).and_return({ 'success' => true })
|
||||
end
|
||||
|
||||
it 'completes successfully without errors' do
|
||||
|
||||
@@ -181,6 +181,11 @@ export const icons = {
|
||||
width: 16,
|
||||
height: 16,
|
||||
},
|
||||
'chevrons-down': {
|
||||
body: `<g fill="none" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4L8 8L12 4" opacity="0.5"/><path d="M4 10L8 14L12 10" opacity="0.75"/><path d="M4 16L8 20L12 16"/></g>`,
|
||||
width: 16,
|
||||
height: 24,
|
||||
},
|
||||
hash: {
|
||||
body: `<g fill="currentColor" stroke="none"><path d="M11.8125 8.3125H9.1875V5.6875H11.8125C11.9285 5.6875 12.0398 5.64141 12.1219 5.55936C12.2039 5.47731 12.25 5.36603 12.25 5.25C12.25 5.13397 12.2039 5.02269 12.1219 4.94064C12.0398 4.85859 11.9285 4.8125 11.8125 4.8125H9.1875V2.1875C9.1875 2.07147 9.14141 1.96019 9.05936 1.87814C8.97731 1.79609 8.86603 1.75 8.75 1.75C8.63397 1.75 8.52269 1.79609 8.44064 1.87814C8.35859 1.96019 8.3125 2.07147 8.3125 2.1875V4.8125H5.6875V2.1875C5.6875 2.07147 5.64141 1.96019 5.55936 1.87814C5.47731 1.79609 5.36603 1.75 5.25 1.75C5.13397 1.75 5.02269 1.79609 4.94064 1.87814C4.85859 1.96019 4.8125 2.07147 4.8125 2.1875V4.8125H2.1875C2.07147 4.8125 1.96019 4.85859 1.87814 4.94064C1.79609 5.02269 1.75 5.13397 1.75 5.25C1.75 5.36603 1.79609 5.47731 1.87814 5.55936C1.96019 5.64141 2.07147 5.6875 2.1875 5.6875H4.8125V8.3125H2.1875C2.07147 8.3125 1.96019 8.35859 1.87814 8.44064C1.79609 8.52269 1.75 8.63397 1.75 8.75C1.75 8.86603 1.79609 8.97731 1.87814 9.05936C1.96019 9.14141 2.07147 9.1875 2.1875 9.1875H4.8125V11.8125C4.8125 11.9285 4.85859 12.0398 4.94064 12.1219C5.02269 12.2039 5.13397 12.25 5.25 12.25C5.36603 12.25 5.47731 12.2039 5.55936 12.1219C5.64141 12.0398 5.6875 11.9285 5.6875 11.8125V9.1875H8.3125V11.8125C8.3125 11.9285 8.35859 12.0398 8.44064 12.1219C8.52269 12.2039 8.63397 12.25 8.75 12.25C8.86603 12.25 8.97731 12.2039 9.05936 12.1219C9.14141 12.0398 9.1875 11.9285 9.1875 11.8125V9.1875H11.8125C11.9285 9.1875 12.0398 9.14141 12.1219 9.05936C12.2039 8.97731 12.25 8.86603 12.25 8.75C12.25 8.63397 12.2039 8.52269 12.1219 8.44064C12.0398 8.35859 11.9285 8.3125 11.8125 8.3125ZM5.6875 8.3125V5.6875H8.3125V8.3125H5.6875Z" fill="currentColor"/></g>`,
|
||||
width: 14,
|
||||
|
||||
Reference in New Issue
Block a user