Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2228a6c0ed | ||
|
|
a7ea02a029 | ||
|
|
2534875cf3 | ||
|
|
fcf660b7d9 | ||
|
|
1af8334ea8 | ||
|
|
13ad505bbb | ||
|
|
3eed8905cc | ||
|
|
f27bbef73b | ||
|
|
ed3059c1aa | ||
|
|
9d591b8f46 | ||
|
|
1afcd36dee | ||
|
|
a3ffb48a47 |
@@ -92,10 +92,18 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
|
||||
def fallback_params(attachment)
|
||||
{
|
||||
fallback_title: attachment['title'],
|
||||
external_url: attachment['url']
|
||||
external_url: attachment['url'] || attachment.dig('payload', 'url')
|
||||
}
|
||||
end
|
||||
|
||||
# Facebook shared posts point to page URLs, not downloadable media URLs.
|
||||
# Keep this Facebook-only so Messenger/Instagram share attachments still use the parent media handling.
|
||||
def normalize_file_type(type)
|
||||
return :fallback if type.to_sym == :share
|
||||
|
||||
super
|
||||
end
|
||||
|
||||
def conversation_params
|
||||
{
|
||||
account_id: @inbox.account_id,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseController
|
||||
before_action :check_admin_authorization?
|
||||
|
||||
def update
|
||||
@account = Current.account
|
||||
finalize = finalizing_account_details?
|
||||
|
||||
@account.assign_attributes(account_params)
|
||||
@account.custom_attributes.merge!(custom_attributes_params)
|
||||
@account.custom_attributes.delete('onboarding_step') if finalize
|
||||
@account.save!
|
||||
|
||||
# TODO: re-enable when the help center generation UI is ready to surface progress
|
||||
# Onboarding::HelpCenterCreationService.new(@account, Current.user).perform if finalize && website.present?
|
||||
|
||||
render 'api/v1/accounts/update', format: :json
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def finalizing_account_details?
|
||||
@account.custom_attributes['onboarding_step'] == 'account_details'
|
||||
end
|
||||
|
||||
def website
|
||||
custom_attributes_params[:website]
|
||||
end
|
||||
|
||||
def account_params
|
||||
params.permit(:name, :locale)
|
||||
end
|
||||
|
||||
def custom_attributes_params
|
||||
params.permit(:industry, :company_size, :timezone, :referral_source, :user_role, :website)
|
||||
end
|
||||
end
|
||||
@@ -58,7 +58,6 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
@account.assign_attributes(account_params.slice(:name, :locale, :domain, :support_email))
|
||||
@account.custom_attributes.merge!(custom_attributes_params)
|
||||
@account.settings.merge!(settings_params)
|
||||
@account.custom_attributes.delete('onboarding_step') if @account.custom_attributes['onboarding_step'] == 'account_details'
|
||||
@account.custom_attributes['onboarding_step'] = 'invite_team' if @account.custom_attributes['onboarding_step'] == 'account_update'
|
||||
@account.save!
|
||||
end
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
class Api::V1::Profile::SessionsController < Api::BaseController
|
||||
before_action :set_session, only: [:destroy]
|
||||
|
||||
def index
|
||||
@sessions = current_user.user_sessions.order(last_activity_at: :desc)
|
||||
@current_client_id = request.headers['client']
|
||||
end
|
||||
|
||||
def destroy
|
||||
if @session.current?(request.headers['client'])
|
||||
render json: { error: I18n.t('profile_settings.sessions.cannot_revoke_current') }, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
revoke_token!(@session.client_id)
|
||||
@session.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_session
|
||||
@session = current_user.user_sessions.find(params[:id])
|
||||
end
|
||||
|
||||
def revoke_token!(client_id)
|
||||
tokens = current_user.tokens
|
||||
tokens.delete(client_id)
|
||||
current_user.update!(tokens: tokens)
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
@@ -1,4 +1,6 @@
|
||||
class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
|
||||
MAX_SESSIONS = 5
|
||||
|
||||
# Prevent session parameter from being passed
|
||||
# Unpermitted parameter: session
|
||||
wrap_parameters format: []
|
||||
@@ -14,12 +16,14 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
|
||||
|
||||
user = find_user_for_authentication
|
||||
return handle_mfa_required(user) if user&.mfa_enabled?
|
||||
return if user && enforce_session_limit_for_password_login(user)
|
||||
|
||||
# Only proceed with standard authentication if no MFA is required
|
||||
super
|
||||
end
|
||||
|
||||
def render_create_success
|
||||
track_user_session
|
||||
render partial: 'devise/auth', formats: [:json], locals: { resource: @resource }
|
||||
end
|
||||
|
||||
@@ -53,6 +57,8 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
|
||||
end
|
||||
|
||||
def handle_sso_authentication
|
||||
return if enforce_session_limit_for_password_login(@resource)
|
||||
|
||||
authenticate_resource_with_sso_token
|
||||
yield @resource if block_given?
|
||||
render_create_success
|
||||
@@ -103,6 +109,7 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
|
||||
end
|
||||
|
||||
def sign_in_mfa_user(user)
|
||||
evict_oldest_session(user) if sessions_limit_reached?(user)
|
||||
@resource = user
|
||||
@token = @resource.create_token
|
||||
@resource.save!
|
||||
@@ -114,6 +121,115 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
|
||||
def render_mfa_error(message_key, status = :bad_request)
|
||||
render json: { error: I18n.t(message_key) }, status: status
|
||||
end
|
||||
|
||||
def sessions_limit_reached?(user)
|
||||
(user.tokens || {}).keys.size >= MAX_SESSIONS
|
||||
end
|
||||
|
||||
# Returns true when the response has been rendered (e.g., 409 picker shown). Browsers see
|
||||
# the picker; non-browser clients (mobile, API) auto-evict so they don't get stuck on a UI
|
||||
# they can't render. If the user is revoking, perform the revoke and let login proceed.
|
||||
def enforce_session_limit_for_password_login(user)
|
||||
if revoking_sessions?
|
||||
revoke_sessions_for_login(user)
|
||||
return false
|
||||
end
|
||||
|
||||
return false unless sessions_limit_reached?(user)
|
||||
|
||||
# Picker only when every token has a tracked session; partial tracking would
|
||||
# show a misleading count, so fall through to silent eviction instead.
|
||||
if browser_request? && user.user_sessions.count >= user.tokens.size
|
||||
handle_sessions_limit_for_login(user)
|
||||
true
|
||||
else
|
||||
evict_oldest_session(user)
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
def browser_request?
|
||||
request.user_agent.to_s.include?('Mozilla')
|
||||
end
|
||||
|
||||
def revoking_sessions?
|
||||
params[:revoke_session_id].present? || params[:revoke_all_sessions].present?
|
||||
end
|
||||
|
||||
def revoke_sessions_for_login(user)
|
||||
if params[:revoke_all_sessions].present?
|
||||
user.tokens = {}
|
||||
user.save!
|
||||
user.user_sessions.destroy_all
|
||||
elsif params[:revoke_session_id].present?
|
||||
session = user.user_sessions.find_by(id: params[:revoke_session_id])
|
||||
return unless session
|
||||
|
||||
user.tokens.delete(session.client_id)
|
||||
user.save!
|
||||
session.destroy!
|
||||
end
|
||||
end
|
||||
|
||||
def evict_oldest_session(user)
|
||||
# Untracked tokens are pre-rollout leftovers and almost always older than any
|
||||
# tracked session; drop those first so freshly tracked logins aren't evicted.
|
||||
return evict_oldest_token(user) if user.user_sessions.count < user.tokens.size
|
||||
|
||||
oldest_session = user.user_sessions.order(Arel.sql('COALESCE(last_activity_at, created_at) ASC')).first
|
||||
return evict_oldest_token(user) unless oldest_session
|
||||
|
||||
user.tokens.delete(oldest_session.client_id)
|
||||
user.save!
|
||||
oldest_session.destroy!
|
||||
end
|
||||
|
||||
# Fallback if a token exists without a UserSession row (e.g., legacy data before tracking shipped).
|
||||
def evict_oldest_token(user)
|
||||
return if user.tokens.blank?
|
||||
|
||||
oldest_client_id = user.tokens.min_by { |_, v| v['expiry'].to_i }&.first
|
||||
return unless oldest_client_id
|
||||
|
||||
user.tokens.delete(oldest_client_id)
|
||||
user.save!
|
||||
end
|
||||
|
||||
def handle_sessions_limit_for_login(user)
|
||||
sessions = user.user_sessions.order(last_activity_at: :desc).map do |session|
|
||||
{
|
||||
id: session.id,
|
||||
browser_name: session.browser_name,
|
||||
browser_version: session.browser_version,
|
||||
device_name: session.device_name,
|
||||
platform_name: session.platform_name,
|
||||
platform_version: session.platform_version,
|
||||
ip_address: session.ip_address,
|
||||
city: session.city,
|
||||
country: session.country,
|
||||
last_activity_at: session.last_activity_at,
|
||||
created_at: session.created_at
|
||||
}
|
||||
end
|
||||
|
||||
render json: {
|
||||
sessions_limit_reached: true,
|
||||
sessions: sessions
|
||||
}, status: :conflict
|
||||
end
|
||||
|
||||
def track_user_session
|
||||
client_id = @token&.try(:client) || response.headers['client']
|
||||
return unless client_id.present? && @resource.present?
|
||||
|
||||
UserSessionTrackingService.new(
|
||||
user: @resource,
|
||||
request: request,
|
||||
client_id: client_id
|
||||
).create_or_update!
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "Session tracking failed: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
DeviseOverrides::SessionsController.prepend_mod_with('DeviseOverrides::SessionsController')
|
||||
|
||||
@@ -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}`);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class OnboardingAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('onboarding', { accountScoped: true });
|
||||
}
|
||||
|
||||
update(data) {
|
||||
return axios.patch(this.url, data);
|
||||
}
|
||||
}
|
||||
|
||||
export default new OnboardingAPI();
|
||||
@@ -49,6 +49,7 @@ const emit = defineEmits(['edit', 'delete']);
|
||||
const { t } = useI18n();
|
||||
|
||||
const STATUS_COMPLETED = 'completed';
|
||||
const STATUS_PROCESSING = 'processing';
|
||||
|
||||
const { formatMessage } = useMessageFormatter();
|
||||
|
||||
@@ -68,9 +69,15 @@ const campaignStatus = computed(() => {
|
||||
: t('CAMPAIGN.LIVE_CHAT.CARD.STATUS.DISABLED');
|
||||
}
|
||||
|
||||
return props.status === STATUS_COMPLETED
|
||||
? t('CAMPAIGN.SMS.CARD.STATUS.COMPLETED')
|
||||
: t('CAMPAIGN.SMS.CARD.STATUS.SCHEDULED');
|
||||
if (props.status === STATUS_COMPLETED) {
|
||||
return t('CAMPAIGN.SMS.CARD.STATUS.COMPLETED');
|
||||
}
|
||||
|
||||
if (props.status === STATUS_PROCESSING) {
|
||||
return t('CAMPAIGN.SMS.CARD.STATUS.PROCESSING');
|
||||
}
|
||||
|
||||
return t('CAMPAIGN.SMS.CARD.STATUS.SCHEDULED');
|
||||
});
|
||||
|
||||
const inboxName = computed(() => props.inbox?.name || '');
|
||||
|
||||
+29
-15
@@ -1,34 +1,48 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import { usePolicy } from 'dashboard/composables/usePolicy';
|
||||
|
||||
const emit = defineEmits(['add', 'import', 'export']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const { checkPermissions } = usePolicy();
|
||||
|
||||
const contactMenuItems = [
|
||||
const contactMenuItems = computed(() => [
|
||||
{
|
||||
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.ADD_CONTACT'),
|
||||
action: 'add',
|
||||
value: 'add',
|
||||
icon: 'i-lucide-plus',
|
||||
},
|
||||
{
|
||||
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.EXPORT_CONTACT'),
|
||||
action: 'export',
|
||||
value: 'export',
|
||||
icon: 'i-lucide-upload',
|
||||
},
|
||||
{
|
||||
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.IMPORT_CONTACT'),
|
||||
action: 'import',
|
||||
value: 'import',
|
||||
icon: 'i-lucide-download',
|
||||
},
|
||||
];
|
||||
...(checkPermissions(['administrator', 'contact_manage'])
|
||||
? [
|
||||
{
|
||||
label: t(
|
||||
'CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.EXPORT_CONTACT'
|
||||
),
|
||||
action: 'export',
|
||||
value: 'export',
|
||||
icon: 'i-lucide-upload',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(checkPermissions(['administrator', 'contact_manage'])
|
||||
? [
|
||||
{
|
||||
label: t(
|
||||
'CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.IMPORT_CONTACT'
|
||||
),
|
||||
action: 'import',
|
||||
value: 'import',
|
||||
icon: 'i-lucide-download',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
const showActionsDropdown = ref(false);
|
||||
|
||||
const handleContactAction = ({ action }) => {
|
||||
|
||||
@@ -31,6 +31,7 @@ import FileBubble from './bubbles/File.vue';
|
||||
import AudioBubble from './bubbles/Audio.vue';
|
||||
import VideoBubble from './bubbles/Video.vue';
|
||||
import EmbedBubble from './bubbles/Embed.vue';
|
||||
import FallbackBubble from './bubbles/Fallback.vue';
|
||||
import InstagramStoryBubble from './bubbles/InstagramStory.vue';
|
||||
import EmailBubble from './bubbles/Email/Index.vue';
|
||||
import UnsupportedBubble from './bubbles/Unsupported.vue';
|
||||
@@ -328,6 +329,8 @@ const componentToRender = computed(() => {
|
||||
if (Array.isArray(props.attachments) && props.attachments.length === 1) {
|
||||
const fileType = props.attachments[0].fileType;
|
||||
|
||||
if (fileType === ATTACHMENT_TYPES.FALLBACK) return FallbackBubble;
|
||||
|
||||
if (!props.content) {
|
||||
if (fileType === ATTACHMENT_TYPES.IMAGE) return ImageBubble;
|
||||
if (fileType === ATTACHMENT_TYPES.FILE) return FileBubble;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import BaseBubble from './Base.vue';
|
||||
import FormattedContent from './Text/FormattedContent.vue';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
|
||||
const { attachments, content } = useMessageContext();
|
||||
|
||||
const attachment = computed(() => attachments.value?.[0] || {});
|
||||
const url = computed(
|
||||
() => attachment.value.dataUrl || attachment.value.data_url
|
||||
);
|
||||
const title = computed(
|
||||
() =>
|
||||
attachment.value.fallbackTitle ||
|
||||
attachment.value.fallback_title ||
|
||||
url.value
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseBubble class="p-3" data-bubble-name="fallback">
|
||||
<FormattedContent v-if="content" :content="content" class="mb-2" />
|
||||
<a
|
||||
v-if="url"
|
||||
:href="url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="block max-w-[320px] truncate text-sm text-n-brand underline"
|
||||
>
|
||||
{{ title }}
|
||||
</a>
|
||||
<span v-else class="text-sm text-n-slate-11">
|
||||
{{ title }}
|
||||
</span>
|
||||
</BaseBubble>
|
||||
</template>
|
||||
@@ -0,0 +1,133 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
sessions: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['revoke', 'revokeAll', 'cancel']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const revokingId = ref(null);
|
||||
const revokingAll = ref(false);
|
||||
|
||||
const sortedSessions = computed(() =>
|
||||
[...props.sessions].sort(
|
||||
(a, b) => new Date(b.created_at) - new Date(a.created_at)
|
||||
)
|
||||
);
|
||||
|
||||
const formatDate = dateStr => {
|
||||
if (!dateStr) return '';
|
||||
return format(parseISO(dateStr), 'MMMM d, yyyy');
|
||||
};
|
||||
|
||||
const formatTime = dateStr => {
|
||||
if (!dateStr) return '';
|
||||
return format(parseISO(dateStr), 'hh:mma');
|
||||
};
|
||||
|
||||
const isUnknown = val => !val || val === 'Unknown' || val === 'Unknown Browser';
|
||||
|
||||
const sessionLabel = session => {
|
||||
const parts = [];
|
||||
if (!isUnknown(session.browser_name)) parts.push(session.browser_name);
|
||||
if (!isUnknown(session.platform_name)) parts.push(session.platform_name);
|
||||
return parts.join(' on ') || t('SESSION_LIMIT.UNKNOWN_DEVICE');
|
||||
};
|
||||
|
||||
const handleRevoke = session => {
|
||||
revokingId.value = session.id;
|
||||
emit('revoke', session.id);
|
||||
};
|
||||
|
||||
const handleRevokeAll = () => {
|
||||
revokingAll.value = true;
|
||||
emit('revokeAll');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full max-w-lg mx-auto">
|
||||
<div
|
||||
class="bg-white shadow dark:bg-n-solid-2 p-11 sm:shadow-lg sm:rounded-lg"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-n-slate-12">
|
||||
{{ $t('SESSION_LIMIT.TITLE') }}
|
||||
</h2>
|
||||
<p class="text-sm text-n-slate-11 mt-2">
|
||||
{{ $t('SESSION_LIMIT.DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
<NextButton
|
||||
type="button"
|
||||
faded
|
||||
sm
|
||||
class="flex-shrink-0 whitespace-nowrap"
|
||||
:label="$t('SESSION_LIMIT.END_ALL')"
|
||||
:is-loading="revokingAll"
|
||||
:disabled="revokingId !== null"
|
||||
@click="handleRevokeAll"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Session List -->
|
||||
<div class="flex flex-col gap-3 max-h-80 overflow-y-auto">
|
||||
<div
|
||||
v-for="session in sortedSessions"
|
||||
:key="session.id"
|
||||
class="flex items-center justify-between gap-4 rounded-xl border border-n-slate-4 bg-n-background px-4 py-3"
|
||||
>
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<Icon
|
||||
icon="i-lucide-monitor"
|
||||
class="size-4 text-n-slate-10 flex-shrink-0"
|
||||
/>
|
||||
<div class="flex flex-col gap-0.5 min-w-0">
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ sessionLabel(session) }}
|
||||
</span>
|
||||
<span class="text-xs text-n-slate-10">
|
||||
{{
|
||||
`${$t('SESSION_LIMIT.STARTED')} ${formatDate(session.created_at)}, ${formatTime(session.created_at)}`
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm font-medium text-n-slate-11 hover:text-n-slate-12 flex-shrink-0 disabled:opacity-50"
|
||||
:disabled="revokingId !== null || revokingAll"
|
||||
@click="handleRevoke(session)"
|
||||
>
|
||||
{{ $t('SESSION_LIMIT.END') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cancel -->
|
||||
<div class="text-center pt-4">
|
||||
<NextButton
|
||||
sm
|
||||
slate
|
||||
link
|
||||
type="button"
|
||||
class="w-full hover:!no-underline"
|
||||
:label="$t('SESSION_LIMIT.CANCEL')"
|
||||
@click="() => emit('cancel')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -52,6 +52,10 @@ export function useAccount() {
|
||||
});
|
||||
};
|
||||
|
||||
const finishOnboarding = async data => {
|
||||
await store.dispatch('accounts/finishOnboarding', data);
|
||||
};
|
||||
|
||||
return {
|
||||
accountId,
|
||||
route,
|
||||
@@ -61,5 +65,6 @@ export function useAccount() {
|
||||
isCloudFeatureEnabled,
|
||||
isOnChatwootCloud,
|
||||
updateAccount,
|
||||
finishOnboarding,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -154,6 +154,11 @@ export const YEAR_IN_REVIEW_EVENTS = Object.freeze({
|
||||
SHARE_CLICKED: 'Year in Review: Share clicked',
|
||||
});
|
||||
|
||||
export const SESSION_EVENTS = Object.freeze({
|
||||
LIMIT_HIT: 'Session limit reached at login',
|
||||
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',
|
||||
|
||||
@@ -88,6 +88,7 @@
|
||||
},
|
||||
"CARD": {
|
||||
"STATUS": {
|
||||
"PROCESSING": "Processing",
|
||||
"COMPLETED": "Completed",
|
||||
"SCHEDULED": "Scheduled"
|
||||
},
|
||||
@@ -146,6 +147,7 @@
|
||||
},
|
||||
"CARD": {
|
||||
"STATUS": {
|
||||
"PROCESSING": "Processing",
|
||||
"COMPLETED": "Completed",
|
||||
"SCHEDULED": "Scheduled"
|
||||
},
|
||||
|
||||
@@ -40,6 +40,7 @@ import whatsappTemplates from './whatsappTemplates.json';
|
||||
import contentTemplates from './contentTemplates.json';
|
||||
import mfa from './mfa.json';
|
||||
import onboarding from './onboarding.json';
|
||||
import sessionLimit from './sessionLimit.json';
|
||||
import yearInReview from './yearInReview.json';
|
||||
|
||||
export default {
|
||||
@@ -85,5 +86,6 @@ export default {
|
||||
...contentTemplates,
|
||||
...mfa,
|
||||
...onboarding,
|
||||
...sessionLimit,
|
||||
...yearInReview,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"SESSION_LIMIT": {
|
||||
"TITLE": "Active session limit reached",
|
||||
"DESCRIPTION": "You have reached your limit of active sessions. Please end a session before logging in.",
|
||||
"END": "End",
|
||||
"END_ALL": "End all sessions",
|
||||
"LOG_IN": "Log in",
|
||||
"CANCEL": "Back to login",
|
||||
"UNKNOWN_DEVICE": "Unknown device",
|
||||
"STARTED": "Started"
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,16 @@
|
||||
"NOTE": "Manage additional security features for your account.",
|
||||
"MFA_BUTTON": "Manage Two-Factor Authentication"
|
||||
},
|
||||
"SESSIONS_SECTION": {
|
||||
"TITLE": "Active Sessions",
|
||||
"NOTE": "These are the devices currently logged in to your account.",
|
||||
"CURRENT": "Current session",
|
||||
"REVOKE": "Revoke",
|
||||
"REVOKE_SUCCESS": "Session revoked successfully",
|
||||
"REVOKE_ERROR": "Unable to revoke session. Please try again.",
|
||||
"FETCH_ERROR": "Unable to fetch sessions. Please try again.",
|
||||
"LAST_ACTIVE": "Last active"
|
||||
},
|
||||
"ACCESS_TOKEN": {
|
||||
"TITLE": "Access Token",
|
||||
"NOTE": "This token can be used if you are building an API based integration",
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const { accountId, currentAccount, updateAccount } = useAccount();
|
||||
const { accountId, currentAccount, finishOnboarding } = useAccount();
|
||||
const { enabledLanguages } = useConfig();
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
|
||||
@@ -195,6 +195,12 @@ const handleWebsiteEnter = () => {
|
||||
websiteInput.value?.blur();
|
||||
};
|
||||
|
||||
const normalizeWebsiteUrl = raw => {
|
||||
const trimmed = (raw || '').trim();
|
||||
if (!trimmed) return '';
|
||||
return /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// Block submit while enrichment is still running so users can't bypass
|
||||
// the form with empty values — the controller would otherwise clear
|
||||
@@ -211,9 +217,27 @@ const handleSubmit = async () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect which enrichable fields the user actually edited *before*
|
||||
// normalizing — otherwise an untouched auto-filled domain
|
||||
// (acme.com -> https://acme.com) compares unequal against the raw snapshot
|
||||
// and gets falsely reported as changed, skewing onboarding telemetry.
|
||||
const init = initialValues.value;
|
||||
const enrichableFields = {
|
||||
website: website.value,
|
||||
company_size: companySize.value,
|
||||
industry: industry.value,
|
||||
};
|
||||
const fieldsChanged = Object.entries(enrichableFields)
|
||||
.filter(([key, val]) => val !== init[key])
|
||||
.map(([key]) => key);
|
||||
|
||||
// Persist with a scheme so downstream consumers (Firecrawl, portal
|
||||
// homepage_link) get a fully-qualified URL regardless of what the user typed.
|
||||
website.value = normalizeWebsiteUrl(website.value);
|
||||
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
await updateAccount({
|
||||
await finishOnboarding({
|
||||
name: accountName.value,
|
||||
locale: locale.value,
|
||||
website: website.value,
|
||||
@@ -224,20 +248,11 @@ const handleSubmit = async () => {
|
||||
user_role: userRole.value,
|
||||
});
|
||||
|
||||
const init = initialValues.value;
|
||||
const enrichableFields = {
|
||||
website: website.value,
|
||||
company_size: companySize.value,
|
||||
industry: industry.value,
|
||||
};
|
||||
|
||||
useTrack(ONBOARDING_EVENTS.ACCOUNT_DETAILS_COMPLETED, {
|
||||
has_enriched_data: Boolean(
|
||||
currentAccount.value?.custom_attributes?.brand_info
|
||||
),
|
||||
fields_changed: Object.entries(enrichableFields)
|
||||
.filter(([key, val]) => val !== init[key])
|
||||
.map(([key]) => key),
|
||||
fields_changed: fieldsChanged,
|
||||
user_role: userRole.value,
|
||||
company_size: companySize.value,
|
||||
industry: industry.value,
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
<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('SESSION_LIMIT.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-scheme="alert"
|
||||
@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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
|
||||
import * as types from '../mutation-types';
|
||||
import AccountAPI from '../../api/account';
|
||||
import OnboardingAPI from '../../api/onboarding';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
import EnterpriseAccountAPI from '../../api/enterprise/account';
|
||||
import { throwErrorMessage } from '../utils/api';
|
||||
@@ -83,6 +84,15 @@ export const actions = {
|
||||
throw new Error(error);
|
||||
}
|
||||
},
|
||||
finishOnboarding: async ({ commit }, payload) => {
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true });
|
||||
try {
|
||||
const response = await OnboardingAPI.update(payload);
|
||||
commit(types.default.EDIT_ACCOUNT, response.data);
|
||||
} finally {
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false });
|
||||
}
|
||||
},
|
||||
delete: async ({ commit }, { id }) => {
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true });
|
||||
try {
|
||||
|
||||
@@ -43,6 +43,15 @@ export const login = async ({
|
||||
mfaToken: error.response.data.mfa_token,
|
||||
};
|
||||
}
|
||||
if (
|
||||
error.response?.status === 409 &&
|
||||
error.response?.data?.sessions_limit_reached
|
||||
) {
|
||||
return {
|
||||
sessionsLimitReached: true,
|
||||
sessions: error.response.data.sessions,
|
||||
};
|
||||
}
|
||||
const loginError = new Error(parseAPIErrorResponse(error));
|
||||
loginError.errorCode = error.response?.data?.error_code;
|
||||
throw loginError;
|
||||
|
||||
@@ -8,6 +8,8 @@ import { useVuelidate } from '@vuelidate/core';
|
||||
import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage';
|
||||
import SessionStorage from 'shared/helpers/sessionStorage';
|
||||
import { useBranding } from 'shared/composables/useBranding';
|
||||
import AnalyticsHelper from 'dashboard/helper/AnalyticsHelper';
|
||||
import { SESSION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
|
||||
// components
|
||||
import SimpleDivider from '../../components/Divider/SimpleDivider.vue';
|
||||
@@ -17,6 +19,7 @@ import Spinner from 'shared/components/Spinner.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import MfaVerification from 'dashboard/components/auth/MfaVerification.vue';
|
||||
import SessionLimitOverlay from 'dashboard/components/auth/SessionLimitOverlay.vue';
|
||||
|
||||
const ERROR_MESSAGES = {
|
||||
'no-account-found': 'LOGIN.OAUTH.NO_ACCOUNT_FOUND',
|
||||
@@ -36,6 +39,7 @@ export default {
|
||||
NextButton,
|
||||
SimpleDivider,
|
||||
MfaVerification,
|
||||
SessionLimitOverlay,
|
||||
Icon,
|
||||
},
|
||||
props: {
|
||||
@@ -68,6 +72,8 @@ export default {
|
||||
error: '',
|
||||
mfaRequired: false,
|
||||
mfaToken: null,
|
||||
sessionsLimitReached: false,
|
||||
limitedSessions: [],
|
||||
};
|
||||
},
|
||||
validations() {
|
||||
@@ -182,6 +188,15 @@ export default {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if sessions limit reached
|
||||
if (result?.sessionsLimitReached) {
|
||||
this.loginApi.showLoading = false;
|
||||
this.sessionsLimitReached = true;
|
||||
this.limitedSessions = result.sessions;
|
||||
AnalyticsHelper.track(SESSION_EVENTS.LIMIT_HIT);
|
||||
return;
|
||||
}
|
||||
|
||||
this.handleImpersonation();
|
||||
this.showAlertMessage(this.$t('LOGIN.API.SUCCESS_MESSAGE'));
|
||||
})
|
||||
@@ -224,6 +239,51 @@ export default {
|
||||
this.mfaToken = null;
|
||||
this.credentials.password = '';
|
||||
},
|
||||
retryLoginWithParams(extraParams) {
|
||||
const credentials = {
|
||||
email: this.email
|
||||
? decodeURIComponent(this.email)
|
||||
: this.credentials.email,
|
||||
password: this.credentials.password,
|
||||
sso_auth_token: this.ssoAuthToken,
|
||||
ssoAccountId: this.ssoAccountId,
|
||||
ssoConversationId: this.ssoConversationId,
|
||||
...extraParams,
|
||||
};
|
||||
|
||||
this.sessionsLimitReached = false;
|
||||
this.limitedSessions = [];
|
||||
this.loginApi.showLoading = true;
|
||||
login(credentials)
|
||||
.then(result => {
|
||||
if (result?.sessionsLimitReached) {
|
||||
this.loginApi.showLoading = false;
|
||||
this.sessionsLimitReached = true;
|
||||
this.limitedSessions = result.sessions;
|
||||
AnalyticsHelper.track(SESSION_EVENTS.LIMIT_HIT);
|
||||
return;
|
||||
}
|
||||
this.handleImpersonation();
|
||||
this.showAlertMessage(this.$t('LOGIN.API.SUCCESS_MESSAGE'));
|
||||
})
|
||||
.catch(response => {
|
||||
this.loginApi.hasErrored = true;
|
||||
this.showAlertMessage(
|
||||
response?.message || this.$t('LOGIN.API.UNAUTH')
|
||||
);
|
||||
});
|
||||
},
|
||||
handleSessionRevoke(sessionId) {
|
||||
this.retryLoginWithParams({ revoke_session_id: sessionId });
|
||||
},
|
||||
handleSessionRevokeAll() {
|
||||
this.retryLoginWithParams({ revoke_all_sessions: true });
|
||||
},
|
||||
handleSessionLimitCancel() {
|
||||
this.sessionsLimitReached = false;
|
||||
this.limitedSessions = [];
|
||||
this.credentials.password = '';
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -255,8 +315,18 @@ export default {
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Session Limit Section -->
|
||||
<section v-if="sessionsLimitReached" class="mt-11">
|
||||
<SessionLimitOverlay
|
||||
:sessions="limitedSessions"
|
||||
@revoke="handleSessionRevoke"
|
||||
@revoke-all="handleSessionRevokeAll"
|
||||
@cancel="handleSessionLimitCancel"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- MFA Verification Section -->
|
||||
<section v-if="mfaRequired" class="mt-11">
|
||||
<section v-else-if="mfaRequired" class="mt-11">
|
||||
<MfaVerification
|
||||
:mfa-token="mfaToken"
|
||||
@verified="handleMfaVerified"
|
||||
|
||||
+17
-3
@@ -47,7 +47,7 @@ class Campaign < ApplicationRecord
|
||||
|
||||
enum campaign_type: { ongoing: 0, one_off: 1 }
|
||||
# TODO : enabled attribute is unneccessary . lets move that to the campaign status with additional statuses like draft, disabled etc.
|
||||
enum campaign_status: { active: 0, completed: 1 }
|
||||
enum campaign_status: { active: 0, completed: 1, processing: 2 }
|
||||
|
||||
has_many :conversations, dependent: :nullify, autosave: true
|
||||
|
||||
@@ -56,13 +56,27 @@ class Campaign < ApplicationRecord
|
||||
|
||||
def trigger!
|
||||
return unless one_off?
|
||||
return if completed?
|
||||
return unless feature_enabled?
|
||||
return unless mark_processing!
|
||||
|
||||
execute_campaign
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def feature_enabled?
|
||||
inbox.inbox_type != 'Whatsapp' || account.feature_enabled?(:whatsapp_campaign)
|
||||
end
|
||||
|
||||
def mark_processing!
|
||||
# Multiple scheduler jobs can pick the same active campaign; lock before flipping status to avoid duplicate sends.
|
||||
with_lock do
|
||||
next if completed? || processing?
|
||||
|
||||
processing!
|
||||
end
|
||||
end
|
||||
|
||||
def execute_campaign
|
||||
case inbox.inbox_type
|
||||
when 'Twilio SMS'
|
||||
@@ -70,7 +84,7 @@ class Campaign < ApplicationRecord
|
||||
when 'Sms'
|
||||
Sms::OneoffSmsCampaignService.new(campaign: self).perform
|
||||
when 'Whatsapp'
|
||||
Whatsapp::OneoffCampaignService.new(campaign: self).perform if account.feature_enabled?(:whatsapp_campaign)
|
||||
Whatsapp::OneoffCampaignService.new(campaign: self).perform
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -51,3 +51,5 @@ class ContactPolicy < ApplicationPolicy
|
||||
@account_user.administrator?
|
||||
end
|
||||
end
|
||||
|
||||
ContactPolicy.prepend_mod_with('ContactPolicy')
|
||||
|
||||
@@ -5,12 +5,10 @@ class Sms::OneoffSmsCampaignService
|
||||
raise "Invalid campaign #{campaign.id}" if campaign.inbox.inbox_type != 'Sms' || !campaign.one_off?
|
||||
raise 'Completed Campaign' if campaign.completed?
|
||||
|
||||
# marks campaign completed so that other jobs won't pick it up
|
||||
campaign.completed!
|
||||
|
||||
audience_label_ids = campaign.audience.select { |audience| audience['type'] == 'Label' }.pluck('id')
|
||||
audience_labels = campaign.account.labels.where(id: audience_label_ids).pluck(:title)
|
||||
process_audience(audience_labels)
|
||||
campaign.completed!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -5,12 +5,10 @@ class Twilio::OneoffSmsCampaignService
|
||||
raise "Invalid campaign #{campaign.id}" if campaign.inbox.inbox_type != 'Twilio SMS' || !campaign.one_off?
|
||||
raise 'Completed Campaign' if campaign.completed?
|
||||
|
||||
# marks campaign completed so that other jobs won't pick it up
|
||||
campaign.completed!
|
||||
|
||||
audience_label_ids = campaign.audience.select { |audience| audience['type'] == 'Label' }.pluck('id')
|
||||
audience_labels = campaign.account.labels.where(id: audience_label_ids).pluck(:title)
|
||||
process_audience(audience_labels)
|
||||
campaign.completed!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
class UserSessionTrackingService
|
||||
def initialize(user:, request:, client_id:)
|
||||
@user = user
|
||||
@request = request
|
||||
@client_id = client_id
|
||||
end
|
||||
|
||||
def create_or_update!
|
||||
session = @user.user_sessions.find_or_initialize_by(client_id: @client_id)
|
||||
session.assign_attributes(session_attributes)
|
||||
session.last_activity_at = Time.current
|
||||
session.save!
|
||||
session
|
||||
end
|
||||
|
||||
def update_activity!
|
||||
session = @user.user_sessions.find_by(client_id: @client_id)
|
||||
return unless session&.should_update_activity?
|
||||
|
||||
session.update_columns(last_activity_at: Time.current) # rubocop:disable Rails/SkipsModelValidations
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def session_attributes
|
||||
browser = Browser.new(@request.user_agent)
|
||||
location = IpLookupService.new.perform(@request.remote_ip)
|
||||
|
||||
{
|
||||
ip_address: @request.remote_ip,
|
||||
user_agent: @request.user_agent,
|
||||
browser_name: browser.name,
|
||||
browser_version: browser.full_version,
|
||||
device_name: browser.device.name,
|
||||
platform_name: browser.platform.name,
|
||||
platform_version: browser.platform.version,
|
||||
city: location&.city,
|
||||
country: location&.country,
|
||||
country_code: location&.country_code
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -3,9 +3,8 @@ class Whatsapp::OneoffCampaignService
|
||||
|
||||
def perform
|
||||
validate_campaign!
|
||||
# marks campaign completed so that other jobs won't pick it up
|
||||
campaign.completed!
|
||||
process_audience(extract_audience_labels)
|
||||
campaign.completed!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -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
|
||||
@@ -15,7 +15,7 @@ DeviseTokenAuth.setup do |config|
|
||||
|
||||
# Sets the max number of concurrent devices per user, which is 10 by default.
|
||||
# After this limit is reached, the oldest tokens will be removed.
|
||||
config.max_number_of_devices = 25
|
||||
config.max_number_of_devices = 10
|
||||
|
||||
# Sometimes it's necessary to make several requests to the API at the same
|
||||
# time. In this case, each request in the batch will need to share the same
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -54,6 +54,7 @@ Rails.application.routes.draw do
|
||||
resource :contact_merge, only: [:create]
|
||||
end
|
||||
resource :bulk_actions, only: [:create]
|
||||
resource :onboarding, only: [:update]
|
||||
resources :agents, only: [:index, :create, :update, :destroy] do
|
||||
post :bulk_create, on: :collection
|
||||
end
|
||||
@@ -432,6 +433,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
|
||||
@@ -1252,6 +1252,26 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_25_093000) do
|
||||
t.index ["name", "account_id"], name: "index_teams_on_name_and_account_id", unique: true
|
||||
end
|
||||
|
||||
create_table "user_sessions", force: :cascade do |t|
|
||||
t.bigint "user_id", null: false
|
||||
t.string "client_id", null: false
|
||||
t.string "ip_address"
|
||||
t.string "user_agent"
|
||||
t.string "browser_name"
|
||||
t.string "browser_version"
|
||||
t.string "device_name"
|
||||
t.string "platform_name"
|
||||
t.string "platform_version"
|
||||
t.string "city"
|
||||
t.string "country"
|
||||
t.string "country_code"
|
||||
t.datetime "last_activity_at"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["user_id", "client_id"], name: "index_user_sessions_on_user_id_and_client_id", unique: true
|
||||
t.index ["user_id"], name: "index_user_sessions_on_user_id"
|
||||
end
|
||||
|
||||
create_table "users", id: :serial, force: :cascade do |t|
|
||||
t.string "provider", default: "email", null: false
|
||||
t.string "uid", default: "", null: false
|
||||
@@ -1324,6 +1344,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_25_093000) do
|
||||
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
||||
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
||||
add_foreign_key "inboxes", "portals"
|
||||
add_foreign_key "user_sessions", "users"
|
||||
create_trigger("accounts_after_insert_row_tr", :generated => true, :compatibility => 1).
|
||||
on("accounts").
|
||||
after(:insert).
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
module Enterprise::ContactPolicy
|
||||
def export?
|
||||
@account_user.custom_role&.permissions&.include?('contact_manage') || super
|
||||
end
|
||||
|
||||
def import?
|
||||
@account_user.custom_role&.permissions&.include?('contact_manage') || super
|
||||
end
|
||||
end
|
||||
@@ -19,7 +19,15 @@ class Enterprise::Billing::ReconcilePlanFeaturesService
|
||||
channel_voice
|
||||
].freeze
|
||||
|
||||
BUSINESS_PLAN_FEATURES = %w[sla custom_roles csat_review_notes conversation_required_attributes advanced_assignment custom_tools].freeze
|
||||
BUSINESS_PLAN_FEATURES = %w[
|
||||
sla
|
||||
custom_roles
|
||||
csat_review_notes
|
||||
conversation_required_attributes
|
||||
advanced_assignment
|
||||
custom_tools
|
||||
companies
|
||||
].freeze
|
||||
ENTERPRISE_PLAN_FEATURES = %w[audit_logs disable_branding saml].freeze
|
||||
PREMIUM_PLAN_FEATURES = (STARTUP_PLAN_FEATURES + BUSINESS_PLAN_FEATURES + ENTERPRISE_PLAN_FEATURES).freeze
|
||||
|
||||
|
||||
@@ -140,6 +140,45 @@ describe Messages::Facebook::MessageBuilder do
|
||||
end
|
||||
end
|
||||
|
||||
[
|
||||
{
|
||||
source_id: 'm_fallback_test',
|
||||
attachment: { type: 'fallback', title: 'Shared link', url: 'https://www.example.com/shared-link' },
|
||||
title: 'Shared link',
|
||||
url: 'https://www.example.com/shared-link'
|
||||
},
|
||||
{
|
||||
source_id: 'm_share_test',
|
||||
attachment: { type: 'share', title: 'Shared Facebook post', payload: { url: 'https://www.facebook.com/example/posts/123' } },
|
||||
title: 'Shared Facebook post',
|
||||
url: 'https://www.facebook.com/example/posts/123'
|
||||
}
|
||||
].each do |message_data|
|
||||
it "stores #{message_data[:attachment][:type]} attachments as fallback links" do
|
||||
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
|
||||
allow(fb_object).to receive(:get_object).and_return(
|
||||
{ first_name: 'Jane', last_name: 'Dae', profile_pic: 'https://chatwoot-assets.local/sample.png' }.with_indifferent_access
|
||||
)
|
||||
expect(Down).not_to receive(:download)
|
||||
|
||||
message_object = {
|
||||
messaging: {
|
||||
sender: { id: '3383290475046708' },
|
||||
recipient: { id: facebook_channel.page_id },
|
||||
message: { mid: message_data[:source_id], attachments: [message_data[:attachment]] }
|
||||
}
|
||||
}.to_json
|
||||
message = Integrations::Facebook::MessageParser.new(message_object)
|
||||
|
||||
described_class.new(message, facebook_channel.inbox).perform
|
||||
|
||||
attachment = facebook_channel.inbox.messages.find_by(source_id: message_data[:source_id]).attachments.first
|
||||
expect(attachment.file_type).to eq('fallback')
|
||||
expect(attachment.fallback_title).to eq(message_data[:title])
|
||||
expect(attachment.external_url).to eq(message_data[:url])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when lock to single conversation' do
|
||||
subject(:mocked_message_builder) do
|
||||
described_class.new(mocked_incoming_fb_text_message, facebook_channel.inbox).perform
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Onboarding API', type: :request do
|
||||
let(:account) { create(:account, domain: 'example.com') }
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
describe 'PATCH /api/v1/accounts/{account.id}/onboarding' do
|
||||
context 'when unauthenticated' do
|
||||
it 'returns unauthorized' do
|
||||
patch "/api/v1/accounts/#{account.id}/onboarding", params: { website: 'acme.com' }, as: :json
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when authenticated as an agent (non-admin)' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
it 'returns unauthorized and does not change the account' do
|
||||
patch "/api/v1/accounts/#{account.id}/onboarding",
|
||||
params: { name: 'Hijacked', website: 'attacker.com' },
|
||||
headers: agent.create_new_auth_token, as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
expect(account.reload.name).not_to eq('Hijacked')
|
||||
end
|
||||
|
||||
it 'does not create a help center portal' do
|
||||
account.update!(custom_attributes: { 'onboarding_step' => 'account_details' })
|
||||
|
||||
expect do
|
||||
patch "/api/v1/accounts/#{account.id}/onboarding",
|
||||
params: { website: 'attacker.com' },
|
||||
headers: agent.create_new_auth_token, as: :json
|
||||
end.not_to change(account.portals, :count)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when finalizing account_details' do
|
||||
before { account.update!(custom_attributes: { 'onboarding_step' => 'account_details' }) }
|
||||
|
||||
it 'saves name and locale' do
|
||||
patch "/api/v1/accounts/#{account.id}/onboarding",
|
||||
params: { name: 'Acme Inc', locale: 'fr' },
|
||||
headers: admin.create_new_auth_token, as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(account.reload.name).to eq('Acme Inc')
|
||||
expect(account.locale).to eq('fr')
|
||||
end
|
||||
|
||||
it 'merges custom_attributes' do
|
||||
patch "/api/v1/accounts/#{account.id}/onboarding",
|
||||
params: { website: 'acme.com', industry: 'tech', company_size: '10-50' },
|
||||
headers: admin.create_new_auth_token, as: :json
|
||||
|
||||
attrs = account.reload.custom_attributes
|
||||
expect(attrs['website']).to eq('acme.com')
|
||||
expect(attrs['industry']).to eq('tech')
|
||||
expect(attrs['company_size']).to eq('10-50')
|
||||
end
|
||||
|
||||
it 'clears onboarding_step' do
|
||||
patch "/api/v1/accounts/#{account.id}/onboarding",
|
||||
params: { website: 'acme.com' },
|
||||
headers: admin.create_new_auth_token, as: :json
|
||||
|
||||
expect(account.reload.custom_attributes).not_to have_key('onboarding_step')
|
||||
end
|
||||
|
||||
it 'invokes HelpCenterCreationService when website is present', skip: 'help center generation wiring disabled until UI is ready' do
|
||||
service = instance_double(Onboarding::HelpCenterCreationService, perform: nil)
|
||||
allow(Onboarding::HelpCenterCreationService).to receive(:new).and_return(service)
|
||||
|
||||
patch "/api/v1/accounts/#{account.id}/onboarding",
|
||||
params: { website: 'acme.com' },
|
||||
headers: admin.create_new_auth_token, as: :json
|
||||
|
||||
expect(Onboarding::HelpCenterCreationService).to have_received(:new) do |arg_account, arg_user|
|
||||
expect(arg_account.id).to eq(account.id)
|
||||
expect(arg_user.id).to eq(admin.id)
|
||||
end
|
||||
expect(service).to have_received(:perform)
|
||||
end
|
||||
|
||||
it 'does not create a help center portal when website is blank' do
|
||||
expect do
|
||||
patch "/api/v1/accounts/#{account.id}/onboarding",
|
||||
params: { name: 'Acme Inc' },
|
||||
headers: admin.create_new_auth_token, as: :json
|
||||
end.not_to change(account.portals, :count)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when onboarding_step is not account_details' do
|
||||
before { account.update!(custom_attributes: { 'onboarding_step' => 'invite_team' }) }
|
||||
|
||||
it 'does not clear onboarding_step' do
|
||||
patch "/api/v1/accounts/#{account.id}/onboarding",
|
||||
params: { website: 'acme.com' },
|
||||
headers: admin.create_new_auth_token, as: :json
|
||||
|
||||
expect(account.reload.custom_attributes['onboarding_step']).to eq('invite_team')
|
||||
end
|
||||
|
||||
it 'does not create a help center portal' do
|
||||
expect do
|
||||
patch "/api/v1/accounts/#{account.id}/onboarding",
|
||||
params: { website: 'acme.com' },
|
||||
headers: admin.create_new_auth_token, as: :json
|
||||
end.not_to change(account.portals, :count)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -302,16 +302,6 @@ RSpec.describe 'Accounts API', type: :request do
|
||||
expect(account.reload.custom_attributes['onboarding_step']).to eq('invite_team')
|
||||
end
|
||||
|
||||
it 'clears onboarding step when current value is account_details' do
|
||||
account.update(custom_attributes: { onboarding_step: 'account_details' })
|
||||
patch "/api/v1/accounts/#{account.id}",
|
||||
params: params,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(account.reload.custom_attributes).not_to have_key('onboarding_step')
|
||||
end
|
||||
|
||||
it 'will not update onboarding step if onboarding step is not present in account custom attributes' do
|
||||
patch "/api/v1/accounts/#{account.id}",
|
||||
params: params,
|
||||
|
||||
@@ -163,4 +163,156 @@ RSpec.describe DeviseOverrides::SessionsController, type: :controller do
|
||||
expect(response).to redirect_to('/frontend/app/login?error=access-denied')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'session limit enforcement' 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' }
|
||||
let(:mobile_ua) { 'okhttp/4.9.3' }
|
||||
|
||||
def seed_token(client_id, expiry_offset_days: 30, with_session: true)
|
||||
user.tokens = user.tokens.merge(
|
||||
client_id => { 'token' => 'x', 'expiry' => (Time.current + expiry_offset_days.days).to_i }
|
||||
)
|
||||
user.save!
|
||||
user.user_sessions.create!(client_id: client_id, last_activity_at: Time.current) if with_session
|
||||
end
|
||||
|
||||
def login_params
|
||||
{ email: user.email, password: 'Test@123456' }
|
||||
end
|
||||
|
||||
context 'when under the limit' do
|
||||
it 'allows login without intervention' do
|
||||
request.env['HTTP_USER_AGENT'] = browser_ua
|
||||
3.times { |i| seed_token("c#{i}", expiry_offset_days: 30) }
|
||||
|
||||
post :create, params: login_params
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when at the limit from a browser with full tracking' do
|
||||
before do
|
||||
request.env['HTTP_USER_AGENT'] = browser_ua
|
||||
5.times { |i| seed_token("c#{i}", expiry_offset_days: 30) }
|
||||
end
|
||||
|
||||
it 'returns 409 with the session list (picker)' do
|
||||
post :create, params: login_params
|
||||
|
||||
expect(response).to have_http_status(:conflict)
|
||||
body = response.parsed_body
|
||||
expect(body['sessions_limit_reached']).to be true
|
||||
expect(body['sessions'].size).to eq(5)
|
||||
end
|
||||
|
||||
it 'does not create a new session row' do
|
||||
expect { post :create, params: login_params }.not_to change(user.user_sessions, :count)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when at the limit from a non-browser client' do
|
||||
before do
|
||||
request.env['HTTP_USER_AGENT'] = mobile_ua
|
||||
5.times { |i| seed_token("c#{i}", expiry_offset_days: 30 + i, with_session: false) }
|
||||
end
|
||||
|
||||
it 'silently evicts the oldest token and lets login proceed' do
|
||||
post :create, params: login_params
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(user.reload.tokens.keys).not_to include('c0')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when at the limit but tracking is partial (legacy tokens present)' do
|
||||
before do
|
||||
request.env['HTTP_USER_AGENT'] = browser_ua
|
||||
# one tracked, four legacy (no user_session rows)
|
||||
seed_token('tracked', expiry_offset_days: 60, with_session: true)
|
||||
4.times { |i| seed_token("legacy#{i}", expiry_offset_days: 10 + i, with_session: false) }
|
||||
end
|
||||
|
||||
it 'silent-evicts instead of showing a partial picker' do
|
||||
post :create, params: login_params
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
|
||||
it 'drops an untracked token first, keeping the tracked session alive' do
|
||||
post :create, params: login_params
|
||||
|
||||
tokens = user.reload.tokens.keys
|
||||
expect(tokens).to include('tracked')
|
||||
# legacy0 expires soonest -> evict_oldest_token picks it
|
||||
expect(tokens).not_to include('legacy0')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when at the limit with full tracking (no legacy gap)' do
|
||||
before do
|
||||
request.env['HTTP_USER_AGENT'] = mobile_ua
|
||||
# Five tracked sessions, varying activity timestamps
|
||||
5.times do |i|
|
||||
seed_token("tracked#{i}", expiry_offset_days: 30)
|
||||
user.user_sessions.find_by(client_id: "tracked#{i}").update!(last_activity_at: (5 - i).days.ago)
|
||||
end
|
||||
end
|
||||
|
||||
it 'evicts the oldest tracked session by last_activity_at' do
|
||||
post :create, params: login_params
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
# tracked0 had the oldest last_activity_at (5 days ago)
|
||||
expect(user.reload.tokens.keys).not_to include('tracked0')
|
||||
expect(user.user_sessions.exists?(client_id: 'tracked0')).to be false
|
||||
end
|
||||
end
|
||||
|
||||
context 'with revoke_session_id during login' do
|
||||
before do
|
||||
request.env['HTTP_USER_AGENT'] = browser_ua
|
||||
5.times { |i| seed_token("c#{i}", expiry_offset_days: 30) }
|
||||
end
|
||||
|
||||
it 'revokes the chosen session and proceeds with login' do
|
||||
target = user.user_sessions.find_by(client_id: 'c2')
|
||||
|
||||
post :create, params: login_params.merge(revoke_session_id: target.id)
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(user.reload.tokens.keys).not_to include('c2')
|
||||
expect(user.user_sessions.exists?(id: target.id)).to be false
|
||||
end
|
||||
end
|
||||
|
||||
context 'with revoke_all_sessions during login' do
|
||||
before do
|
||||
request.env['HTTP_USER_AGENT'] = browser_ua
|
||||
5.times { |i| seed_token("c#{i}", expiry_offset_days: 30) }
|
||||
end
|
||||
|
||||
it 'wipes all sessions and tokens, then proceeds with login' do
|
||||
post :create, params: login_params.merge(revoke_all_sessions: true)
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(user.reload.tokens.keys).not_to include('c0', 'c1', 'c2', 'c3', 'c4')
|
||||
# the new login adds one fresh token
|
||||
expect(user.tokens.keys.size).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
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: login_params }.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
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Enterprise::ContactPolicy', type: :policy do
|
||||
subject(:contact_policy) { ContactPolicy }
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let(:contact) { create(:contact, account: account) }
|
||||
let(:custom_role) { create(:custom_role, account: account, permissions: ['contact_manage']) }
|
||||
let(:agent) { create(:user) }
|
||||
let(:account_user) { create(:account_user, user: agent, account: account, role: :agent, custom_role: custom_role) }
|
||||
let(:agent_context) { { user: agent, account: account, account_user: account_user } }
|
||||
|
||||
permissions :export? do
|
||||
context 'when agent has contact_manage permission' do
|
||||
it { expect(contact_policy).to permit(agent_context, contact) }
|
||||
end
|
||||
end
|
||||
|
||||
permissions :import? do
|
||||
context 'when agent has contact_manage permission' do
|
||||
it { expect(contact_policy).to permit(agent_context, contact) }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -40,5 +40,13 @@ RSpec.describe TriggerScheduledItemsJob do
|
||||
expect(Campaigns::TriggerOneoffCampaignJob).to receive(:perform_later).with(campaign).once
|
||||
described_class.perform_now
|
||||
end
|
||||
|
||||
it 'does not trigger campaigns that are already processing' do
|
||||
create(:campaign, inbox: twilio_inbox, account: account, campaign_status: :processing)
|
||||
|
||||
expect(Campaigns::TriggerOneoffCampaignJob).not_to receive(:perform_later)
|
||||
|
||||
described_class.perform_now
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -83,6 +83,38 @@ RSpec.describe Campaign do
|
||||
campaign.save!
|
||||
campaign.trigger!
|
||||
end
|
||||
|
||||
it 'marks the campaign as processing before triggering the service' do
|
||||
campaign.save!
|
||||
sms_service = double
|
||||
|
||||
expect(Twilio::OneoffSmsCampaignService).to receive(:new).with(campaign: campaign).and_return(sms_service)
|
||||
expect(sms_service).to receive(:perform) do
|
||||
expect(campaign.reload.processing?).to be true
|
||||
end
|
||||
|
||||
campaign.trigger!
|
||||
end
|
||||
|
||||
it 'does not trigger a processing campaign again' do
|
||||
campaign.save!
|
||||
campaign.processing!
|
||||
|
||||
expect(Twilio::OneoffSmsCampaignService).not_to receive(:new)
|
||||
|
||||
campaign.trigger!
|
||||
end
|
||||
|
||||
it 'keeps the campaign processing when triggering fails' do
|
||||
campaign.save!
|
||||
sms_service = double
|
||||
|
||||
expect(Twilio::OneoffSmsCampaignService).to receive(:new).with(campaign: campaign).and_return(sms_service)
|
||||
expect(sms_service).to receive(:perform).and_raise(StandardError, 'provider error')
|
||||
|
||||
expect { campaign.trigger! }.to raise_error(StandardError, 'provider error')
|
||||
expect(campaign.reload.processing?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when SMS campaign' do
|
||||
@@ -107,6 +139,22 @@ RSpec.describe Campaign do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when WhatsApp campaign feature is disabled' do
|
||||
let(:account) { create(:account) }
|
||||
let(:whatsapp_channel) do
|
||||
create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud', validate_provider_config: false, sync_templates: false)
|
||||
end
|
||||
let(:campaign) { create(:campaign, account: account, inbox: whatsapp_channel.inbox) }
|
||||
|
||||
it 'does not mark the campaign as processing' do
|
||||
expect(Whatsapp::OneoffCampaignService).not_to receive(:new)
|
||||
|
||||
campaign.trigger!
|
||||
|
||||
expect(campaign.reload.active?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when Website campaign' do
|
||||
let(:campaign) { build(:campaign) }
|
||||
|
||||
|
||||
@@ -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,87 @@
|
||||
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)
|
||||
|
||||
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 '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
|
||||
@@ -45,6 +45,19 @@ describe Sms::OneoffSmsCampaignService do
|
||||
expect(campaign.reload.completed?).to be true
|
||||
end
|
||||
|
||||
it 'marks the campaign completed after processing the audience' do
|
||||
contact = create(:contact, :with_phone_number, account: account)
|
||||
contact.update_labels([label1.title])
|
||||
|
||||
expect(sms_channel).to receive(:send_text_message) do
|
||||
expect(campaign.reload.completed?).to be false
|
||||
end
|
||||
|
||||
sms_campaign_service.perform
|
||||
|
||||
expect(campaign.reload.completed?).to be true
|
||||
end
|
||||
|
||||
it 'uses liquid template service to process campaign message' do
|
||||
contact = create(:contact, :with_phone_number, account: account)
|
||||
contact.update_labels([label1.title])
|
||||
|
||||
@@ -61,6 +61,24 @@ describe Twilio::OneoffSmsCampaignService do
|
||||
expect(campaign.reload.completed?).to be true
|
||||
end
|
||||
|
||||
it 'marks the campaign completed after processing the audience' do
|
||||
contact = create(:contact, :with_phone_number, account: account)
|
||||
contact.update_labels([label1.title])
|
||||
|
||||
expect(twilio_messages).to receive(:create).with(
|
||||
body: campaign.message,
|
||||
messaging_service_sid: twilio_sms.messaging_service_sid,
|
||||
to: contact.phone_number,
|
||||
status_callback: 'http://localhost:3000/twilio/delivery_status'
|
||||
) do
|
||||
expect(campaign.reload.completed?).to be false
|
||||
end
|
||||
|
||||
sms_campaign_service.perform
|
||||
|
||||
expect(campaign.reload.completed?).to be true
|
||||
end
|
||||
|
||||
it 'uses liquid template service to process campaign message' do
|
||||
contact = create(:contact, :with_phone_number, account: account)
|
||||
contact.update_labels([label1.title])
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
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) }
|
||||
let(:geo_result) { OpenStruct.new(city: 'Mountain View', country: 'United States', country_code: 'US') }
|
||||
let(:ip_lookup) { instance_double(IpLookupService, perform: geo_result) }
|
||||
|
||||
before { allow(IpLookupService).to receive(:new).and_return(ip_lookup) }
|
||||
|
||||
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, browser, and geo metadata on the new session', :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')
|
||||
expect(session.city).to eq('Mountain View')
|
||||
expect(session.country).to eq('United States')
|
||||
expect(session.country_code).to eq('US')
|
||||
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
|
||||
|
||||
it 'handles missing geo data gracefully' do
|
||||
allow(ip_lookup).to receive(:perform).and_return(nil)
|
||||
|
||||
service.create_or_update!
|
||||
|
||||
session = user.user_sessions.last
|
||||
expect(session.city).to be_nil
|
||||
expect(session.country).to be_nil
|
||||
expect(session.country_code).to be_nil
|
||||
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
|
||||
@@ -82,6 +82,19 @@ describe Whatsapp::OneoffCampaignService do
|
||||
expect(campaign.reload.completed?).to be true
|
||||
end
|
||||
|
||||
it 'marks the campaign completed after processing the audience' do
|
||||
contact = create(:contact, :with_phone_number, account: account)
|
||||
contact.update_labels([label1.title])
|
||||
|
||||
expect(whatsapp_channel).to receive(:send_template) do
|
||||
expect(campaign.reload.completed?).to be false
|
||||
end
|
||||
|
||||
described_class.new(campaign: campaign).perform
|
||||
|
||||
expect(campaign.reload.completed?).to be true
|
||||
end
|
||||
|
||||
it 'processes contacts with matching labels' do
|
||||
contact_with_label1, contact_with_label2, contact_with_both_labels =
|
||||
create_list(:contact, 3, :with_phone_number, account: account)
|
||||
|
||||
@@ -145,6 +145,34 @@ inbox_create_payload:
|
||||
$ref: ./request/inbox/create_payload.yml
|
||||
inbox_update_payload:
|
||||
$ref: ./request/inbox/update_payload.yml
|
||||
inbox_create_web_widget_channel_payload:
|
||||
$ref: ./request/inbox/channels/create_web_widget_channel_payload.yml
|
||||
inbox_create_api_channel_payload:
|
||||
$ref: ./request/inbox/channels/create_api_channel_payload.yml
|
||||
inbox_create_email_channel_payload:
|
||||
$ref: ./request/inbox/channels/create_email_channel_payload.yml
|
||||
inbox_create_line_channel_payload:
|
||||
$ref: ./request/inbox/channels/create_line_channel_payload.yml
|
||||
inbox_create_telegram_channel_payload:
|
||||
$ref: ./request/inbox/channels/create_telegram_channel_payload.yml
|
||||
inbox_create_whatsapp_channel_payload:
|
||||
$ref: ./request/inbox/channels/create_whatsapp_channel_payload.yml
|
||||
inbox_create_sms_channel_payload:
|
||||
$ref: ./request/inbox/channels/create_sms_channel_payload.yml
|
||||
inbox_update_web_widget_channel_payload:
|
||||
$ref: ./request/inbox/channels/update_web_widget_channel_payload.yml
|
||||
inbox_update_api_channel_payload:
|
||||
$ref: ./request/inbox/channels/update_api_channel_payload.yml
|
||||
inbox_update_email_channel_payload:
|
||||
$ref: ./request/inbox/channels/update_email_channel_payload.yml
|
||||
inbox_update_line_channel_payload:
|
||||
$ref: ./request/inbox/channels/update_line_channel_payload.yml
|
||||
inbox_update_telegram_channel_payload:
|
||||
$ref: ./request/inbox/channels/update_telegram_channel_payload.yml
|
||||
inbox_update_whatsapp_channel_payload:
|
||||
$ref: ./request/inbox/channels/update_whatsapp_channel_payload.yml
|
||||
inbox_update_sms_channel_payload:
|
||||
$ref: ./request/inbox/channels/update_sms_channel_payload.yml
|
||||
|
||||
# Team
|
||||
team_create_update_payload:
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
type: object
|
||||
title: API channel
|
||||
required:
|
||||
- type
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: ['api']
|
||||
example: api
|
||||
webhook_url:
|
||||
type: string
|
||||
description: Webhook URL for API channel inbox callbacks
|
||||
example: 'https://example.com/webhook'
|
||||
hmac_mandatory:
|
||||
type: boolean
|
||||
description: Require HMAC verification for incoming API channel messages
|
||||
example: false
|
||||
additional_attributes:
|
||||
type: object
|
||||
description: Additional attributes stored on contacts created through the API channel
|
||||
example:
|
||||
source: mobile_app
|
||||
@@ -0,0 +1,90 @@
|
||||
type: object
|
||||
title: Email channel
|
||||
required:
|
||||
- type
|
||||
- email
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: ['email']
|
||||
example: email
|
||||
email:
|
||||
type: string
|
||||
description: Email address for the inbox
|
||||
example: support@example.com
|
||||
imap_enabled:
|
||||
type: boolean
|
||||
description: Enable IMAP for inbound emails
|
||||
example: true
|
||||
imap_login:
|
||||
type: string
|
||||
description: IMAP login username
|
||||
example: support@example.com
|
||||
imap_password:
|
||||
type: string
|
||||
description: IMAP login password
|
||||
example: your-imap-password
|
||||
imap_address:
|
||||
type: string
|
||||
description: IMAP server address
|
||||
example: imap.example.com
|
||||
imap_port:
|
||||
type: integer
|
||||
description: IMAP server port
|
||||
example: 993
|
||||
imap_enable_ssl:
|
||||
type: boolean
|
||||
description: Enable SSL for IMAP
|
||||
example: true
|
||||
imap_authentication:
|
||||
type: string
|
||||
description: IMAP authentication method
|
||||
example: plain
|
||||
smtp_enabled:
|
||||
type: boolean
|
||||
description: Enable SMTP for outbound emails
|
||||
example: true
|
||||
smtp_login:
|
||||
type: string
|
||||
description: SMTP login username
|
||||
example: support@example.com
|
||||
smtp_password:
|
||||
type: string
|
||||
description: SMTP login password
|
||||
example: your-smtp-password
|
||||
smtp_address:
|
||||
type: string
|
||||
description: SMTP server address
|
||||
example: smtp.example.com
|
||||
smtp_port:
|
||||
type: integer
|
||||
description: SMTP server port
|
||||
example: 587
|
||||
smtp_domain:
|
||||
type: string
|
||||
description: SMTP HELO domain
|
||||
example: example.com
|
||||
smtp_enable_starttls_auto:
|
||||
type: boolean
|
||||
description: Automatically enable STARTTLS for SMTP
|
||||
example: true
|
||||
smtp_enable_ssl_tls:
|
||||
type: boolean
|
||||
description: Enable SSL/TLS for SMTP
|
||||
example: false
|
||||
smtp_openssl_verify_mode:
|
||||
type: string
|
||||
description: OpenSSL certificate verification mode for SMTP
|
||||
example: none
|
||||
smtp_authentication:
|
||||
type: string
|
||||
description: SMTP authentication method
|
||||
example: login
|
||||
provider:
|
||||
type: string
|
||||
description: Email provider
|
||||
example: google
|
||||
verified_for_sending:
|
||||
type: boolean
|
||||
description: Whether the inbox is verified for sending emails
|
||||
example: false
|
||||
@@ -0,0 +1,24 @@
|
||||
type: object
|
||||
title: LINE channel
|
||||
required:
|
||||
- type
|
||||
- line_channel_id
|
||||
- line_channel_secret
|
||||
- line_channel_token
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: ['line']
|
||||
example: line
|
||||
line_channel_id:
|
||||
type: string
|
||||
description: LINE channel ID
|
||||
example: '1234567890'
|
||||
line_channel_secret:
|
||||
type: string
|
||||
description: LINE channel secret
|
||||
example: line-channel-secret
|
||||
line_channel_token:
|
||||
type: string
|
||||
description: LINE channel access token
|
||||
example: line-channel-token
|
||||
@@ -0,0 +1,20 @@
|
||||
type: object
|
||||
title: SMS channel
|
||||
required:
|
||||
- type
|
||||
- phone_number
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: ['sms']
|
||||
example: sms
|
||||
phone_number:
|
||||
type: string
|
||||
description: SMS phone number
|
||||
example: '+15551234567'
|
||||
provider_config:
|
||||
type: object
|
||||
description: Provider-specific SMS configuration
|
||||
example:
|
||||
account_id: your-account-id
|
||||
application_id: your-application-id
|
||||
@@ -0,0 +1,14 @@
|
||||
type: object
|
||||
title: Telegram channel
|
||||
required:
|
||||
- type
|
||||
- bot_token
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: ['telegram']
|
||||
example: telegram
|
||||
bot_token:
|
||||
type: string
|
||||
description: Telegram bot token
|
||||
example: 123456789:telegram-bot-token
|
||||
@@ -0,0 +1,66 @@
|
||||
type: object
|
||||
title: Website channel
|
||||
required:
|
||||
- type
|
||||
- website_url
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: ['web_widget']
|
||||
example: web_widget
|
||||
website_url:
|
||||
type: string
|
||||
description: URL at which the widget will be loaded
|
||||
example: 'https://example.com'
|
||||
welcome_title:
|
||||
type: string
|
||||
description: Welcome title to be displayed on the widget
|
||||
example: 'Welcome to our support'
|
||||
welcome_tagline:
|
||||
type: string
|
||||
description: Welcome tagline to be displayed on the widget
|
||||
example: 'We are here to help you'
|
||||
widget_color:
|
||||
type: string
|
||||
description: A Hex-color string used to customize the widget
|
||||
example: '#FF5733'
|
||||
reply_time:
|
||||
type: string
|
||||
description: Expected reply time shown on the widget
|
||||
enum: ['in_a_few_minutes', 'in_a_few_hours', 'in_a_day']
|
||||
example: in_a_few_minutes
|
||||
pre_chat_form_enabled:
|
||||
type: boolean
|
||||
description: Enable the pre-chat form before starting a conversation
|
||||
example: false
|
||||
pre_chat_form_options:
|
||||
type: object
|
||||
description: Pre-chat form configuration
|
||||
example:
|
||||
pre_chat_message: Share your queries or comments here.
|
||||
pre_chat_fields:
|
||||
- field_type: standard
|
||||
label: Email Id
|
||||
name: emailAddress
|
||||
type: email
|
||||
required: true
|
||||
enabled: true
|
||||
continuity_via_email:
|
||||
type: boolean
|
||||
description: Continue conversations over email when the contact leaves the website
|
||||
example: true
|
||||
hmac_mandatory:
|
||||
type: boolean
|
||||
description: Require HMAC verification for contacts using the widget
|
||||
example: false
|
||||
allowed_domains:
|
||||
type: string
|
||||
description: Comma-separated list of domains where the widget is allowed to load
|
||||
example: example.com
|
||||
selected_feature_flags:
|
||||
type: array
|
||||
description: Enabled widget feature flags
|
||||
items:
|
||||
type: string
|
||||
enum: ['attachments', 'emoji_picker', 'end_conversation', 'use_inbox_avatar_for_bot', 'allow_mobile_webview']
|
||||
example: ['attachments', 'emoji_picker', 'end_conversation']
|
||||
@@ -0,0 +1,80 @@
|
||||
oneOf:
|
||||
- type: object
|
||||
title: WhatsApp Cloud channel
|
||||
required:
|
||||
- type
|
||||
- phone_number
|
||||
- provider
|
||||
- provider_config
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: ['whatsapp']
|
||||
example: whatsapp
|
||||
phone_number:
|
||||
type: string
|
||||
description: WhatsApp phone number
|
||||
example: '+15551234567'
|
||||
provider:
|
||||
type: string
|
||||
description: WhatsApp provider
|
||||
enum: ['whatsapp_cloud']
|
||||
example: whatsapp_cloud
|
||||
provider_config:
|
||||
type: object
|
||||
description: WhatsApp Cloud provider configuration
|
||||
required:
|
||||
- api_key
|
||||
- phone_number_id
|
||||
- business_account_id
|
||||
properties:
|
||||
api_key:
|
||||
type: string
|
||||
description: WhatsApp Cloud API key
|
||||
example: your-api-key
|
||||
phone_number_id:
|
||||
type: string
|
||||
description: Phone number ID for WhatsApp Cloud
|
||||
example: your-phone-number-id
|
||||
business_account_id:
|
||||
type: string
|
||||
description: Business account ID for WhatsApp Cloud
|
||||
example: your-business-account-id
|
||||
example:
|
||||
api_key: your-api-key
|
||||
phone_number_id: your-phone-number-id
|
||||
business_account_id: your-business-account-id
|
||||
- type: object
|
||||
title: Legacy 360dialog WhatsApp channel
|
||||
deprecated: true
|
||||
required:
|
||||
- type
|
||||
- phone_number
|
||||
- provider_config
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: ['whatsapp']
|
||||
example: whatsapp
|
||||
phone_number:
|
||||
type: string
|
||||
description: WhatsApp phone number
|
||||
example: '+15551234567'
|
||||
provider:
|
||||
type: string
|
||||
description: Legacy 360dialog provider. Omit this field or use `default` only for existing deprecated 360dialog setups.
|
||||
enum: ['default']
|
||||
deprecated: true
|
||||
example: default
|
||||
provider_config:
|
||||
type: object
|
||||
description: Legacy 360dialog provider configuration
|
||||
required:
|
||||
- api_key
|
||||
properties:
|
||||
api_key:
|
||||
type: string
|
||||
description: 360dialog API key
|
||||
example: your-api-key
|
||||
example:
|
||||
api_key: your-api-key
|
||||
@@ -0,0 +1,16 @@
|
||||
type: object
|
||||
title: API channel settings
|
||||
properties:
|
||||
webhook_url:
|
||||
type: string
|
||||
description: Webhook URL for API channel inbox callbacks
|
||||
example: 'https://example.com/webhook'
|
||||
hmac_mandatory:
|
||||
type: boolean
|
||||
description: Require HMAC verification for incoming API channel messages
|
||||
example: false
|
||||
additional_attributes:
|
||||
type: object
|
||||
description: Additional attributes stored on contacts created through the API channel
|
||||
example:
|
||||
source: mobile_app
|
||||
@@ -0,0 +1,83 @@
|
||||
type: object
|
||||
title: Email channel settings
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
description: Email address for the inbox
|
||||
example: support@example.com
|
||||
imap_enabled:
|
||||
type: boolean
|
||||
description: Enable IMAP for inbound emails
|
||||
example: true
|
||||
imap_login:
|
||||
type: string
|
||||
description: IMAP login username
|
||||
example: support@example.com
|
||||
imap_password:
|
||||
type: string
|
||||
description: IMAP login password
|
||||
example: your-imap-password
|
||||
imap_address:
|
||||
type: string
|
||||
description: IMAP server address
|
||||
example: imap.example.com
|
||||
imap_port:
|
||||
type: integer
|
||||
description: IMAP server port
|
||||
example: 993
|
||||
imap_enable_ssl:
|
||||
type: boolean
|
||||
description: Enable SSL for IMAP
|
||||
example: true
|
||||
imap_authentication:
|
||||
type: string
|
||||
description: IMAP authentication method
|
||||
example: plain
|
||||
smtp_enabled:
|
||||
type: boolean
|
||||
description: Enable SMTP for outbound emails
|
||||
example: true
|
||||
smtp_login:
|
||||
type: string
|
||||
description: SMTP login username
|
||||
example: support@example.com
|
||||
smtp_password:
|
||||
type: string
|
||||
description: SMTP login password
|
||||
example: your-smtp-password
|
||||
smtp_address:
|
||||
type: string
|
||||
description: SMTP server address
|
||||
example: smtp.example.com
|
||||
smtp_port:
|
||||
type: integer
|
||||
description: SMTP server port
|
||||
example: 587
|
||||
smtp_domain:
|
||||
type: string
|
||||
description: SMTP HELO domain
|
||||
example: example.com
|
||||
smtp_enable_starttls_auto:
|
||||
type: boolean
|
||||
description: Automatically enable STARTTLS for SMTP
|
||||
example: true
|
||||
smtp_enable_ssl_tls:
|
||||
type: boolean
|
||||
description: Enable SSL/TLS for SMTP
|
||||
example: false
|
||||
smtp_openssl_verify_mode:
|
||||
type: string
|
||||
description: OpenSSL certificate verification mode for SMTP
|
||||
example: none
|
||||
smtp_authentication:
|
||||
type: string
|
||||
description: SMTP authentication method
|
||||
example: login
|
||||
provider:
|
||||
type: string
|
||||
description: Email provider
|
||||
example: google
|
||||
verified_for_sending:
|
||||
type: boolean
|
||||
description: Whether the inbox is verified for sending emails
|
||||
example: false
|
||||
@@ -0,0 +1,15 @@
|
||||
type: object
|
||||
title: LINE channel settings
|
||||
properties:
|
||||
line_channel_id:
|
||||
type: string
|
||||
description: LINE channel ID
|
||||
example: '1234567890'
|
||||
line_channel_secret:
|
||||
type: string
|
||||
description: LINE channel secret
|
||||
example: line-channel-secret
|
||||
line_channel_token:
|
||||
type: string
|
||||
description: LINE channel access token
|
||||
example: line-channel-token
|
||||
@@ -0,0 +1,15 @@
|
||||
type: object
|
||||
title: SMS channel settings
|
||||
properties:
|
||||
phone_number:
|
||||
type: string
|
||||
description: SMS phone number
|
||||
example: '+15551234567'
|
||||
provider_config:
|
||||
type: object
|
||||
description: Provider-specific SMS configuration
|
||||
example:
|
||||
api_key: your-api-key
|
||||
api_secret: your-api-secret
|
||||
application_id: your-application-id
|
||||
account_id: your-account-id
|
||||
@@ -0,0 +1,7 @@
|
||||
type: object
|
||||
title: Telegram channel settings
|
||||
properties:
|
||||
bot_token:
|
||||
type: string
|
||||
description: Telegram bot token
|
||||
example: 123456789:telegram-bot-token
|
||||
@@ -0,0 +1,59 @@
|
||||
type: object
|
||||
title: Website channel settings
|
||||
properties:
|
||||
website_url:
|
||||
type: string
|
||||
description: URL at which the widget will be loaded
|
||||
example: 'https://example.com'
|
||||
welcome_title:
|
||||
type: string
|
||||
description: Welcome title to be displayed on the widget
|
||||
example: 'Welcome to our support'
|
||||
welcome_tagline:
|
||||
type: string
|
||||
description: Welcome tagline to be displayed on the widget
|
||||
example: 'We are here to help you'
|
||||
widget_color:
|
||||
type: string
|
||||
description: A Hex-color string used to customize the widget
|
||||
example: '#FF5733'
|
||||
reply_time:
|
||||
type: string
|
||||
description: Expected reply time shown on the widget
|
||||
enum: ['in_a_few_minutes', 'in_a_few_hours', 'in_a_day']
|
||||
example: in_a_few_minutes
|
||||
pre_chat_form_enabled:
|
||||
type: boolean
|
||||
description: Enable the pre-chat form before starting a conversation
|
||||
example: false
|
||||
pre_chat_form_options:
|
||||
type: object
|
||||
description: Pre-chat form configuration
|
||||
example:
|
||||
pre_chat_message: Share your queries or comments here.
|
||||
pre_chat_fields:
|
||||
- field_type: standard
|
||||
label: Email Id
|
||||
name: emailAddress
|
||||
type: email
|
||||
required: true
|
||||
enabled: true
|
||||
continuity_via_email:
|
||||
type: boolean
|
||||
description: Continue conversations over email when the contact leaves the website
|
||||
example: true
|
||||
hmac_mandatory:
|
||||
type: boolean
|
||||
description: Require HMAC verification for contacts using the widget
|
||||
example: false
|
||||
allowed_domains:
|
||||
type: string
|
||||
description: Comma-separated list of domains where the widget is allowed to load
|
||||
example: example.com
|
||||
selected_feature_flags:
|
||||
type: array
|
||||
description: Enabled widget feature flags
|
||||
items:
|
||||
type: string
|
||||
enum: ['attachments', 'emoji_picker', 'end_conversation', 'use_inbox_avatar_for_bot', 'allow_mobile_webview']
|
||||
example: ['attachments', 'emoji_picker', 'end_conversation']
|
||||
@@ -0,0 +1,32 @@
|
||||
type: object
|
||||
title: WhatsApp channel settings
|
||||
properties:
|
||||
phone_number:
|
||||
type: string
|
||||
description: WhatsApp phone number
|
||||
example: '+15551234567'
|
||||
provider:
|
||||
type: string
|
||||
description: WhatsApp provider. `default` is supported only for existing deprecated 360dialog setups.
|
||||
enum: ['whatsapp_cloud', 'default']
|
||||
example: whatsapp_cloud
|
||||
provider_config:
|
||||
type: object
|
||||
description: WhatsApp provider configuration. Cloud channels use `api_key`, `phone_number_id`, and `business_account_id`; legacy 360dialog channels use `api_key`.
|
||||
properties:
|
||||
api_key:
|
||||
type: string
|
||||
description: Provider API key
|
||||
example: your-api-key
|
||||
phone_number_id:
|
||||
type: string
|
||||
description: Phone number ID for WhatsApp Cloud
|
||||
example: your-phone-number-id
|
||||
business_account_id:
|
||||
type: string
|
||||
description: Business account ID for WhatsApp Cloud
|
||||
example: your-business-account-id
|
||||
example:
|
||||
api_key: your-api-key
|
||||
phone_number_id: your-phone-number-id
|
||||
business_account_id: your-business-account-id
|
||||
@@ -2,87 +2,129 @@ type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: The name of the inbox
|
||||
description: The name of the inbox.
|
||||
example: 'Support'
|
||||
avatar:
|
||||
type: string
|
||||
format: binary
|
||||
description: Image file for avatar
|
||||
description: Image file for avatar.
|
||||
greeting_enabled:
|
||||
type: boolean
|
||||
description: Enable greeting message
|
||||
description: Enable greeting message.
|
||||
example: true
|
||||
greeting_message:
|
||||
type: string
|
||||
description: Greeting message to be displayed on the widget
|
||||
description: Greeting message to send when greeting messages are enabled.
|
||||
example: Hello, how can I help you?
|
||||
enable_email_collect:
|
||||
type: boolean
|
||||
description: Enable email collection
|
||||
description: |
|
||||
Enable email collection.
|
||||
|
||||
Available for: `Website`
|
||||
example: true
|
||||
csat_survey_enabled:
|
||||
type: boolean
|
||||
description: Enable CSAT survey
|
||||
description: Enable CSAT survey.
|
||||
example: true
|
||||
csat_config:
|
||||
type: object
|
||||
description: CSAT survey configuration.
|
||||
properties:
|
||||
display_type:
|
||||
type: string
|
||||
description: Display style for the CSAT survey.
|
||||
enum: ['emoji', 'star']
|
||||
example: emoji
|
||||
message:
|
||||
type: string
|
||||
description: Message shown with the CSAT survey.
|
||||
example: Please rate your conversation
|
||||
button_text:
|
||||
type: string
|
||||
description: Text shown on the CSAT survey button.
|
||||
example: Please rate us
|
||||
language:
|
||||
type: string
|
||||
description: Language code for the CSAT survey.
|
||||
example: en
|
||||
survey_rules:
|
||||
type: object
|
||||
description: Rules that decide when to show the CSAT survey.
|
||||
properties:
|
||||
operator:
|
||||
type: string
|
||||
example: contains
|
||||
values:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
example: ['billing']
|
||||
enable_auto_assignment:
|
||||
type: boolean
|
||||
description: Enable Auto Assignment
|
||||
description: Enable Auto Assignment.
|
||||
example: true
|
||||
working_hours_enabled:
|
||||
type: boolean
|
||||
description: Enable working hours
|
||||
description: Enable working hours.
|
||||
example: true
|
||||
out_of_office_message:
|
||||
type: string
|
||||
description: Out of office message to be displayed on the widget
|
||||
description: Out of office message to send outside working hours.
|
||||
example: We are currently out of office. Please leave a message and we will get back to you.
|
||||
timezone:
|
||||
type: string
|
||||
description: Timezone of the inbox
|
||||
description: Timezone of the inbox.
|
||||
example: 'America/New_York'
|
||||
allow_messages_after_resolved:
|
||||
type: boolean
|
||||
description: Allow messages after conversation is resolved
|
||||
description: |
|
||||
Allow messages after conversation is resolved.
|
||||
|
||||
Available for: `Website`
|
||||
example: true
|
||||
lock_to_single_conversation:
|
||||
type: boolean
|
||||
description: Lock to single conversation
|
||||
description: |
|
||||
Lock contact messages to a single active conversation.
|
||||
|
||||
Available for: `API` `LINE` `Telegram` `WhatsApp` `SMS`
|
||||
example: true
|
||||
portal_id:
|
||||
type: integer
|
||||
description: Id of the help center portal to attach to the inbox
|
||||
description: Id of the help center portal to attach to the inbox.
|
||||
example: 1
|
||||
sender_name_type:
|
||||
type: string
|
||||
description: Sender name type for the inbox
|
||||
description: |
|
||||
Sender name type for outbound email replies.
|
||||
|
||||
Available for: `Website` `Email`
|
||||
enum: ['friendly', 'professional']
|
||||
example: 'friendly'
|
||||
business_name:
|
||||
type: string
|
||||
description: Business name for the inbox
|
||||
description: |
|
||||
Business name for outbound email replies.
|
||||
|
||||
Available for: `Website` `Email`
|
||||
example: 'My Business'
|
||||
channel:
|
||||
type: object
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
description: Type of the channel
|
||||
enum:
|
||||
['web_widget', 'api', 'email', 'line', 'telegram', 'whatsapp', 'sms']
|
||||
example: web_widget
|
||||
website_url:
|
||||
type: string
|
||||
description: URL at which the widget will be loaded
|
||||
example: 'https://example.com'
|
||||
welcome_title:
|
||||
type: string
|
||||
description: Welcome title to be displayed on the widget
|
||||
example: 'Welcome to our support'
|
||||
welcome_tagline:
|
||||
type: string
|
||||
description: Welcome tagline to be displayed on the widget
|
||||
example: 'We are here to help you'
|
||||
widget_color:
|
||||
type: string
|
||||
description: A Hex-color string used to customize the widget
|
||||
example: '#FF5733'
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/inbox_create_web_widget_channel_payload'
|
||||
- $ref: '#/components/schemas/inbox_create_api_channel_payload'
|
||||
- $ref: '#/components/schemas/inbox_create_email_channel_payload'
|
||||
- $ref: '#/components/schemas/inbox_create_line_channel_payload'
|
||||
- $ref: '#/components/schemas/inbox_create_telegram_channel_payload'
|
||||
- $ref: '#/components/schemas/inbox_create_whatsapp_channel_payload'
|
||||
- $ref: '#/components/schemas/inbox_create_sms_channel_payload'
|
||||
discriminator:
|
||||
propertyName: type
|
||||
mapping:
|
||||
web_widget: '#/components/schemas/inbox_create_web_widget_channel_payload'
|
||||
api: '#/components/schemas/inbox_create_api_channel_payload'
|
||||
email: '#/components/schemas/inbox_create_email_channel_payload'
|
||||
line: '#/components/schemas/inbox_create_line_channel_payload'
|
||||
telegram: '#/components/schemas/inbox_create_telegram_channel_payload'
|
||||
whatsapp: '#/components/schemas/inbox_create_whatsapp_channel_payload'
|
||||
sms: '#/components/schemas/inbox_create_sms_channel_payload'
|
||||
|
||||
@@ -2,81 +2,119 @@ type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: The name of the inbox
|
||||
description: The name of the inbox.
|
||||
example: 'Support'
|
||||
avatar:
|
||||
type: string
|
||||
format: binary
|
||||
description: Image file for avatar
|
||||
description: Image file for avatar.
|
||||
greeting_enabled:
|
||||
type: boolean
|
||||
description: Enable greeting message
|
||||
description: Enable greeting message.
|
||||
example: true
|
||||
greeting_message:
|
||||
type: string
|
||||
description: Greeting message to be displayed on the widget
|
||||
description: Greeting message to send when greeting messages are enabled.
|
||||
example: Hello, how can I help you?
|
||||
enable_email_collect:
|
||||
type: boolean
|
||||
description: Enable email collection
|
||||
description: |
|
||||
Enable email collection.
|
||||
|
||||
Available for: `Website`
|
||||
example: true
|
||||
csat_survey_enabled:
|
||||
type: boolean
|
||||
description: Enable CSAT survey
|
||||
description: Enable CSAT survey.
|
||||
example: true
|
||||
csat_config:
|
||||
type: object
|
||||
description: CSAT survey configuration.
|
||||
properties:
|
||||
display_type:
|
||||
type: string
|
||||
description: Display style for the CSAT survey.
|
||||
enum: ['emoji', 'star']
|
||||
example: emoji
|
||||
message:
|
||||
type: string
|
||||
description: Message shown with the CSAT survey.
|
||||
example: Please rate your conversation
|
||||
button_text:
|
||||
type: string
|
||||
description: Text shown on the CSAT survey button.
|
||||
example: Please rate us
|
||||
language:
|
||||
type: string
|
||||
description: Language code for the CSAT survey.
|
||||
example: en
|
||||
survey_rules:
|
||||
type: object
|
||||
description: Rules that decide when to show the CSAT survey.
|
||||
properties:
|
||||
operator:
|
||||
type: string
|
||||
example: contains
|
||||
values:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
example: ['billing']
|
||||
enable_auto_assignment:
|
||||
type: boolean
|
||||
description: Enable Auto Assignment
|
||||
description: Enable Auto Assignment.
|
||||
example: true
|
||||
working_hours_enabled:
|
||||
type: boolean
|
||||
description: Enable working hours
|
||||
description: Enable working hours.
|
||||
example: true
|
||||
out_of_office_message:
|
||||
type: string
|
||||
description: Out of office message to be displayed on the widget
|
||||
description: Out of office message to send outside working hours.
|
||||
example: We are currently out of office. Please leave a message and we will get back to you.
|
||||
timezone:
|
||||
type: string
|
||||
description: Timezone of the inbox
|
||||
description: Timezone of the inbox.
|
||||
example: 'America/New_York'
|
||||
allow_messages_after_resolved:
|
||||
type: boolean
|
||||
description: Allow messages after conversation is resolved
|
||||
description: |
|
||||
Allow messages after conversation is resolved.
|
||||
|
||||
Available for: `Website`
|
||||
example: true
|
||||
lock_to_single_conversation:
|
||||
type: boolean
|
||||
description: Lock to single conversation
|
||||
description: |
|
||||
Lock contact messages to a single active conversation.
|
||||
|
||||
Available for: `API` `LINE` `Telegram` `WhatsApp` `SMS`
|
||||
example: true
|
||||
portal_id:
|
||||
type: integer
|
||||
description: Id of the help center portal to attach to the inbox
|
||||
description: Id of the help center portal to attach to the inbox.
|
||||
example: 1
|
||||
sender_name_type:
|
||||
type: string
|
||||
description: Sender name type for the inbox
|
||||
description: |
|
||||
Sender name type for outbound email replies.
|
||||
|
||||
Available for: `Website` `Email`
|
||||
enum: ['friendly', 'professional']
|
||||
example: 'friendly'
|
||||
business_name:
|
||||
type: string
|
||||
description: Business name for the inbox
|
||||
description: |
|
||||
Business name for outbound email replies.
|
||||
|
||||
Available for: `Website` `Email`
|
||||
example: 'My Business'
|
||||
channel:
|
||||
type: object
|
||||
properties:
|
||||
website_url:
|
||||
type: string
|
||||
description: URL at which the widget will be loaded
|
||||
example: 'https://example.com'
|
||||
welcome_title:
|
||||
type: string
|
||||
description: Welcome title to be displayed on the widget
|
||||
example: 'Welcome to our support'
|
||||
welcome_tagline:
|
||||
type: string
|
||||
description: Welcome tagline to be displayed on the widget
|
||||
example: 'We are here to help you'
|
||||
widget_color:
|
||||
type: string
|
||||
description: A Hex-color string used to customize the widget
|
||||
example: '#FF5733'
|
||||
anyOf:
|
||||
- $ref: '#/components/schemas/inbox_update_web_widget_channel_payload'
|
||||
- $ref: '#/components/schemas/inbox_update_api_channel_payload'
|
||||
- $ref: '#/components/schemas/inbox_update_email_channel_payload'
|
||||
- $ref: '#/components/schemas/inbox_update_line_channel_payload'
|
||||
- $ref: '#/components/schemas/inbox_update_telegram_channel_payload'
|
||||
- $ref: '#/components/schemas/inbox_update_whatsapp_channel_payload'
|
||||
- $ref: '#/components/schemas/inbox_update_sms_channel_payload'
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
post:
|
||||
tags:
|
||||
- Inboxes
|
||||
operationId: inboxCreation
|
||||
summary: Create an inbox
|
||||
description: You can create more than one website inbox in each account
|
||||
security:
|
||||
- userApiKey: []
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/account_id'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/inbox_create_payload'
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/inbox'
|
||||
'404':
|
||||
description: Inbox not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/bad_request_error'
|
||||
'403':
|
||||
description: Access denied
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/bad_request_error'
|
||||
@@ -49,6 +49,121 @@ post:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/inbox_create_payload'
|
||||
examples:
|
||||
web_widget:
|
||||
summary: Website inbox
|
||||
value:
|
||||
name: Support
|
||||
greeting_enabled: true
|
||||
greeting_message: Hello, how can I help you?
|
||||
enable_email_collect: true
|
||||
enable_auto_assignment: true
|
||||
working_hours_enabled: true
|
||||
timezone: America/New_York
|
||||
allow_messages_after_resolved: true
|
||||
channel:
|
||||
type: web_widget
|
||||
website_url: https://example.com
|
||||
welcome_title: Welcome to our support
|
||||
welcome_tagline: We are here to help you
|
||||
widget_color: '#FF5733'
|
||||
reply_time: in_a_few_minutes
|
||||
pre_chat_form_enabled: false
|
||||
continuity_via_email: true
|
||||
hmac_mandatory: false
|
||||
selected_feature_flags:
|
||||
- attachments
|
||||
- emoji_picker
|
||||
- end_conversation
|
||||
api:
|
||||
summary: API channel
|
||||
value:
|
||||
name: API Inbox
|
||||
greeting_enabled: true
|
||||
greeting_message: Hello, how can I help you?
|
||||
enable_auto_assignment: true
|
||||
working_hours_enabled: true
|
||||
timezone: America/New_York
|
||||
channel:
|
||||
type: api
|
||||
webhook_url: https://example.com/webhook
|
||||
hmac_mandatory: false
|
||||
additional_attributes:
|
||||
source: mobile_app
|
||||
email:
|
||||
summary: Email channel
|
||||
value:
|
||||
name: Email Inbox
|
||||
greeting_enabled: true
|
||||
greeting_message: Hello, how can I help you?
|
||||
enable_auto_assignment: true
|
||||
working_hours_enabled: true
|
||||
timezone: America/New_York
|
||||
channel:
|
||||
type: email
|
||||
email: support@example.com
|
||||
imap_enabled: false
|
||||
smtp_enabled: false
|
||||
line:
|
||||
summary: LINE channel
|
||||
value:
|
||||
name: LINE Inbox
|
||||
greeting_enabled: true
|
||||
greeting_message: Hello, how can I help you?
|
||||
enable_auto_assignment: true
|
||||
working_hours_enabled: true
|
||||
timezone: America/New_York
|
||||
channel:
|
||||
type: line
|
||||
line_channel_id: '1234567890'
|
||||
line_channel_secret: line-channel-secret
|
||||
line_channel_token: line-channel-token
|
||||
telegram:
|
||||
summary: Telegram channel
|
||||
value:
|
||||
name: Telegram Inbox
|
||||
greeting_enabled: true
|
||||
greeting_message: Hello, how can I help you?
|
||||
enable_auto_assignment: true
|
||||
working_hours_enabled: true
|
||||
timezone: America/New_York
|
||||
channel:
|
||||
type: telegram
|
||||
bot_token: 123456789:telegram-bot-token
|
||||
whatsapp:
|
||||
summary: WhatsApp channel
|
||||
value:
|
||||
name: WhatsApp Inbox
|
||||
greeting_enabled: true
|
||||
greeting_message: Hello, how can I help you?
|
||||
enable_auto_assignment: true
|
||||
working_hours_enabled: true
|
||||
timezone: America/New_York
|
||||
channel:
|
||||
type: whatsapp
|
||||
phone_number: '+15551234567'
|
||||
provider: whatsapp_cloud
|
||||
provider_config:
|
||||
api_key: your-api-key
|
||||
phone_number_id: your-phone-number-id
|
||||
business_account_id: your-business-account-id
|
||||
sms:
|
||||
summary: SMS channel
|
||||
value:
|
||||
name: SMS Inbox
|
||||
greeting_enabled: true
|
||||
greeting_message: Hello, how can I help you?
|
||||
enable_auto_assignment: true
|
||||
working_hours_enabled: true
|
||||
timezone: America/New_York
|
||||
channel:
|
||||
type: sms
|
||||
phone_number: '+15551234567'
|
||||
provider_config:
|
||||
api_key: your-api-key
|
||||
api_secret: your-api-secret
|
||||
application_id: your-application-id
|
||||
account_id: your-account-id
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
|
||||
@@ -55,6 +55,114 @@ patch:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/inbox_update_payload'
|
||||
examples:
|
||||
web_widget:
|
||||
summary: Website inbox settings
|
||||
value:
|
||||
name: Support
|
||||
greeting_enabled: true
|
||||
greeting_message: Hello, how can I help you?
|
||||
enable_email_collect: true
|
||||
enable_auto_assignment: true
|
||||
working_hours_enabled: true
|
||||
timezone: America/New_York
|
||||
allow_messages_after_resolved: true
|
||||
channel:
|
||||
website_url: https://example.com
|
||||
welcome_title: Welcome to our support
|
||||
welcome_tagline: We are here to help you
|
||||
widget_color: '#FF5733'
|
||||
reply_time: in_a_few_minutes
|
||||
pre_chat_form_enabled: false
|
||||
continuity_via_email: true
|
||||
hmac_mandatory: false
|
||||
selected_feature_flags:
|
||||
- attachments
|
||||
- emoji_picker
|
||||
- end_conversation
|
||||
api:
|
||||
summary: API channel settings
|
||||
value:
|
||||
name: API Inbox
|
||||
greeting_enabled: true
|
||||
greeting_message: Hello, how can I help you?
|
||||
enable_auto_assignment: true
|
||||
working_hours_enabled: true
|
||||
timezone: America/New_York
|
||||
channel:
|
||||
webhook_url: https://example.com/webhook
|
||||
hmac_mandatory: false
|
||||
additional_attributes:
|
||||
source: mobile_app
|
||||
email:
|
||||
summary: Email channel settings
|
||||
value:
|
||||
name: Email Inbox
|
||||
greeting_enabled: true
|
||||
greeting_message: Hello, how can I help you?
|
||||
enable_auto_assignment: true
|
||||
working_hours_enabled: true
|
||||
timezone: America/New_York
|
||||
channel:
|
||||
email: support@example.com
|
||||
imap_enabled: false
|
||||
smtp_enabled: false
|
||||
line:
|
||||
summary: LINE channel settings
|
||||
value:
|
||||
name: LINE Inbox
|
||||
greeting_enabled: true
|
||||
greeting_message: Hello, how can I help you?
|
||||
enable_auto_assignment: true
|
||||
working_hours_enabled: true
|
||||
timezone: America/New_York
|
||||
channel:
|
||||
line_channel_id: '1234567890'
|
||||
line_channel_secret: line-channel-secret
|
||||
line_channel_token: line-channel-token
|
||||
telegram:
|
||||
summary: Telegram channel settings
|
||||
value:
|
||||
name: Telegram Inbox
|
||||
greeting_enabled: true
|
||||
greeting_message: Hello, how can I help you?
|
||||
enable_auto_assignment: true
|
||||
working_hours_enabled: true
|
||||
timezone: America/New_York
|
||||
channel:
|
||||
bot_token: 123456789:telegram-bot-token
|
||||
whatsapp:
|
||||
summary: WhatsApp channel settings
|
||||
value:
|
||||
name: WhatsApp Inbox
|
||||
greeting_enabled: true
|
||||
greeting_message: Hello, how can I help you?
|
||||
enable_auto_assignment: true
|
||||
working_hours_enabled: true
|
||||
timezone: America/New_York
|
||||
channel:
|
||||
phone_number: '+15551234567'
|
||||
provider: whatsapp_cloud
|
||||
provider_config:
|
||||
api_key: your-api-key
|
||||
phone_number_id: your-phone-number-id
|
||||
business_account_id: your-business-account-id
|
||||
sms:
|
||||
summary: SMS channel settings
|
||||
value:
|
||||
name: SMS Inbox
|
||||
greeting_enabled: true
|
||||
greeting_message: Hello, how can I help you?
|
||||
enable_auto_assignment: true
|
||||
working_hours_enabled: true
|
||||
timezone: America/New_York
|
||||
channel:
|
||||
phone_number: '+15551234567'
|
||||
provider_config:
|
||||
api_key: your-api-key
|
||||
api_secret: your-api-secret
|
||||
application_id: your-application-id
|
||||
account_id: your-account-id
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
|
||||
+1226
-79
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user