Compare commits

..
Author SHA1 Message Date
Shivam Mishra a0438a8465 fix: help center perms 2025-05-29 13:30:59 +05:30
84 changed files with 909 additions and 1821 deletions
+2 -2
View File
@@ -77,8 +77,8 @@ RUN curl -L https://github.com/DarthSim/overmind/releases/download/v2.1.0/overmi
WORKDIR /workspace WORKDIR /workspace
RUN chown vscode:vscode /workspace RUN chown vscode:vscode /workspace
# set up node js, pnpm and claude code in single layer # set up node js and pnpm in single layer
RUN npm install -g pnpm@${PNPM_VERSION} @anthropic-ai/claude-code \ RUN npm install -g pnpm@${PNPM_VERSION} \
&& npm cache clean --force && npm cache clean --force
# Switch to vscode user # Switch to vscode user
+4 -13
View File
@@ -4,26 +4,17 @@
"dockerComposeFile": "docker-compose.yml", "dockerComposeFile": "docker-compose.yml",
"settings": { "settings": {
"terminal.integrated.shell.linux": "/bin/zsh", "terminal.integrated.shell.linux": "/bin/zsh"
"extensions.showRecommendationsOnlyOnDemand": true,
"editor.formatOnSave": true,
"files.trimTrailingWhitespace": true,
"files.insertFinalNewline": true,
"search.exclude": {
"**/node_modules": true,
"**/tmp": true,
"**/log": true,
"**/coverage": true,
"**/public/packs": true
}
}, },
// Add the IDs of extensions you want installed when the container is created. // Add the IDs of extensions you want installed when the container is created.
"extensions": [ "extensions": [
"Shopify.ruby-lsp", "rebornix.Ruby",
"misogi.ruby-rubocop", "misogi.ruby-rubocop",
"wingrunr21.vscode-ruby",
"davidpallinder.rails-test-runner", "davidpallinder.rails-test-runner",
"eamodio.gitlens",
"github.copilot", "github.copilot",
"mrmlnc.vscode-duplicate" "mrmlnc.vscode-duplicate"
], ],
-8
View File
@@ -4,13 +4,5 @@ sed -i -e '/POSTGRES_HOST/ s/=.*/=localhost/' .env
sed -i -e '/SMTP_ADDRESS/ s/=.*/=localhost/' .env sed -i -e '/SMTP_ADDRESS/ s/=.*/=localhost/' .env
sed -i -e "/FRONTEND_URL/ s/=.*/=https:\/\/$CODESPACE_NAME-3000.app.github.dev/" .env sed -i -e "/FRONTEND_URL/ s/=.*/=https:\/\/$CODESPACE_NAME-3000.app.github.dev/" .env
# Setup Claude Code API key if available
if [ -n "$CLAUDE_CODE_API_KEY" ]; then
mkdir -p ~/.claude
echo '{"apiKeyHelper": "~/.claude/anthropic_key.sh"}' > ~/.claude/settings.json
echo "echo \"$CLAUDE_CODE_API_KEY\"" > ~/.claude/anthropic_key.sh
chmod +x ~/.claude/anthropic_key.sh
fi
# codespaces make the ports public # codespaces make the ports public
gh codespace ports visibility 3000:public 3036:public 8025:public -c $CODESPACE_NAME gh codespace ports visibility 3000:public 3036:public 8025:public -c $CODESPACE_NAME
-1
View File
@@ -41,7 +41,6 @@ run:
force_run: force_run:
rm -f ./.overmind.sock rm -f ./.overmind.sock
rm -f tmp/pids/*.pid
overmind start -f Procfile.dev overmind start -f Procfile.dev
debug: debug:
@@ -29,11 +29,6 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
head :ok head :ok
end end
def reset_access_token
@agent_bot.access_token.regenerate_token
@agent_bot.reload
end
private private
def agent_bot def agent_bot
@@ -1,23 +1,32 @@
class Api::V1::Accounts::Google::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController class Api::V1::Accounts::Google::AuthorizationsController < Api::V1::Accounts::BaseController
include GoogleConcern include GoogleConcern
before_action :check_authorization
def create def create
email = params[:authorization][:email]
redirect_url = google_client.auth_code.authorize_url( redirect_url = google_client.auth_code.authorize_url(
{ {
redirect_uri: "#{base_url}/google/callback", redirect_uri: "#{base_url}/google/callback",
scope: scope, scope: 'email profile https://mail.google.com/',
response_type: 'code', response_type: 'code',
prompt: 'consent', # the oauth flow does not return a refresh token, this is supposed to fix it prompt: 'consent', # the oauth flow does not return a refresh token, this is supposed to fix it
access_type: 'offline', # the default is 'online' access_type: 'offline', # the default is 'online'
state: state,
client_id: GlobalConfigService.load('GOOGLE_OAUTH_CLIENT_ID', nil) client_id: GlobalConfigService.load('GOOGLE_OAUTH_CLIENT_ID', nil)
} }
) )
if redirect_url if redirect_url
cache_key = "google::#{email.downcase}"
::Redis::Alfred.setex(cache_key, Current.account.id, 5.minutes)
render json: { success: true, url: redirect_url } render json: { success: true, url: redirect_url }
else else
render json: { success: false }, status: :unprocessable_entity render json: { success: false }, status: :unprocessable_entity
end end
end end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end end
@@ -1,6 +1,7 @@
class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts::BaseController
include InstagramConcern include InstagramConcern
include Instagram::IntegrationHelper include Instagram::IntegrationHelper
before_action :check_authorization
def create def create
# https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#step-1--get-authorization # https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#step-1--get-authorization
@@ -20,4 +21,10 @@ class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts
render json: { success: false }, status: :unprocessable_entity render json: { success: false }, status: :unprocessable_entity
end end
end end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end end
@@ -1,19 +1,28 @@
class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts::BaseController
include MicrosoftConcern include MicrosoftConcern
before_action :check_authorization
def create def create
email = params[:authorization][:email]
redirect_url = microsoft_client.auth_code.authorize_url( redirect_url = microsoft_client.auth_code.authorize_url(
{ {
redirect_uri: "#{base_url}/microsoft/callback", redirect_uri: "#{base_url}/microsoft/callback",
scope: scope, scope: 'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send openid profile',
state: state,
prompt: 'consent' prompt: 'consent'
} }
) )
if redirect_url if redirect_url
cache_key = "microsoft::#{email.downcase}"
::Redis::Alfred.setex(cache_key, Current.account.id, 5.minutes)
render json: { success: true, url: redirect_url } render json: { success: true, url: redirect_url }
else else
render json: { success: false }, status: :unprocessable_entity render json: { success: false }, status: :unprocessable_entity
end end
end end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end end
@@ -1,23 +0,0 @@
class Api::V1::Accounts::OauthAuthorizationController < Api::V1::Accounts::BaseController
before_action :check_authorization
protected
def scope
''
end
def state
Current.account.to_sgid(expires_in: 15.minutes).to_s
end
def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end
@@ -38,11 +38,6 @@ class Api::V1::ProfilesController < Api::BaseController
head :ok head :ok
end end
def reset_access_token
@user.access_token.regenerate_token
@user.reload
end
private private
def set_user def set_user
+2 -2
View File
@@ -14,7 +14,7 @@ module GoogleConcern
private private
def scope def base_url
'https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email https://mail.google.com/' ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end end
end end
@@ -15,7 +15,7 @@ module MicrosoftConcern
private private
def scope def base_url
'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send openid profile' ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end end
end end
@@ -12,16 +12,6 @@ class Google::CallbacksController < OauthCallbackController
private private
def verify_scopes
granted_scopes = parsed_body['scope']&.split || []
required_scopes = scope.split
missing_scopes = required_scopes - granted_scopes
return if missing_scopes.empty?
raise StandardError, I18n.t('errors.oauth.insufficient_scopes', scopes: missing_scopes.join(', '))
end
def provider_name def provider_name
'google' 'google'
end end
@@ -34,13 +24,4 @@ class Google::CallbacksController < OauthCallbackController
# from GoogleConcern # from GoogleConcern
google_client google_client
end end
def handle_error(exception)
ChatwootExceptionTracker.new(exception).capture_exception
error_message = exception.message || I18n.t('errors.oauth.authorization_failed')
redirect_url = "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/app/accounts/#{account.id}/settings/inboxes/new/email"
redirect_to "#{redirect_url}?error=#{CGI.escape(error_message)}"
end
end end
+8 -18
View File
@@ -5,10 +5,11 @@ class OauthCallbackController < ApplicationController
redirect_uri: "#{base_url}/#{provider_name}/callback" redirect_uri: "#{base_url}/#{provider_name}/callback"
) )
verify_scopes
handle_response handle_response
::Redis::Alfred.delete(cache_key)
rescue StandardError => e rescue StandardError => e
handle_error(e) ChatwootExceptionTracker.new(e).capture_exception
redirect_to '/'
end end
private private
@@ -63,17 +64,8 @@ class OauthCallbackController < ApplicationController
raise NotImplementedError raise NotImplementedError
end end
def verify_scopes def cache_key
true "#{provider_name}::#{users_data['email'].downcase}"
end
def failure_redirect_url
'/'
end
def handle_error(exception)
ChatwootExceptionTracker.new(exception).capture_exception
redirect_to failure_redirect_url
end end
def create_channel_with_inbox def create_channel_with_inbox
@@ -93,14 +85,12 @@ class OauthCallbackController < ApplicationController
decoded_token[0] decoded_token[0]
end end
def account_from_signed_id def account_id
raise 'Missing state variable' if params[:state].blank? ::Redis::Alfred.get(cache_key)
GlobalID::Locator.locate_signed(params[:state])
end end
def account def account
@account ||= account_from_signed_id @account ||= Account.find(account_id)
end end
# Fallback name, for when name field is missing from users_data # Fallback name, for when name field is missing from users_data
@@ -1,7 +1,7 @@
class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController
before_action :ensure_custom_domain_request, only: [:show, :index] before_action :ensure_custom_domain_request, only: [:show, :index]
before_action :portal before_action :portal
before_action :set_category, except: [:index, :show, :tracking_pixel] before_action :set_category, except: [:index, :show]
before_action :set_article, only: [:show] before_action :set_article, only: [:show]
layout 'portal' layout 'portal'
@@ -15,21 +15,6 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
def show; end def show; end
def tracking_pixel
@article = @portal.articles.find_by(slug: permitted_params[:article_slug])
return head :not_found unless @article
@article.increment_view_count if @article.published?
# Serve the 1x1 tracking pixel with 24-hour private cache
# Private cache bypasses CDN but allows browser caching to prevent duplicate views from same user
expires_in 24.hours, public: false
response.headers['Content-Type'] = 'image/png'
pixel_path = Rails.public_path.join('assets/images/tracking-pixel.png')
send_file pixel_path, type: 'image/png', disposition: 'inline'
end
private private
def limit_results def limit_results
@@ -54,6 +39,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
def set_article def set_article
@article = @portal.articles.find_by(slug: permitted_params[:article_slug]) @article = @portal.articles.find_by(slug: permitted_params[:article_slug])
@article.increment_view_count if @article.published?
@parsed_content = render_article_content(@article.content) @parsed_content = render_article_content(@article.content)
end end
@@ -21,10 +21,6 @@ class AgentBotsAPI extends ApiClient {
deleteAgentBotAvatar(botId) { deleteAgentBotAvatar(botId) {
return axios.delete(`${this.url}/${botId}/avatar`); return axios.delete(`${this.url}/${botId}/avatar`);
} }
resetAccessToken(botId) {
return axios.post(`${this.url}/${botId}/reset_access_token`);
}
} }
export default new AgentBotsAPI(); export default new AgentBotsAPI();
-4
View File
@@ -102,8 +102,4 @@ export default {
const urlData = endPoints('resendConfirmation'); const urlData = endPoints('resendConfirmation');
return axios.post(urlData.url); return axios.post(urlData.url);
}, },
resetAccessToken() {
const urlData = endPoints('resetAccessToken');
return axios.post(urlData.url);
},
}; };
@@ -51,9 +51,6 @@ const endPoints = {
resendConfirmation: { resendConfirmation: {
url: '/api/v1/profile/resend_confirmation', url: '/api/v1/profile/resend_confirmation',
}, },
resetAccessToken: {
url: '/api/v1/profile/reset_access_token',
},
}; };
export default page => { export default page => {
@@ -9,6 +9,5 @@ describe('#AgentBotsAPI', () => {
expect(AgentBotsAPI).toHaveProperty('create'); expect(AgentBotsAPI).toHaveProperty('create');
expect(AgentBotsAPI).toHaveProperty('update'); expect(AgentBotsAPI).toHaveProperty('update');
expect(AgentBotsAPI).toHaveProperty('delete'); expect(AgentBotsAPI).toHaveProperty('delete');
expect(AgentBotsAPI).toHaveProperty('resetAccessToken');
}); });
}); });
@@ -101,7 +101,7 @@ select {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' version='1.1' width='32' height='24' viewBox='0 0 32 24'><polygon points='0,0 32,0 16,24' style='fill: rgb%28110, 111, 115%29'></polygon></svg>"); background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' version='1.1' width='32' height='24' viewBox='0 0 32 24'><polygon points='0,0 32,0 16,24' style='fill: rgb%28110, 111, 115%29'></polygon></svg>");
background-size: 9px 6px; background-size: 9px 6px;
@apply field-base h-10 bg-origin-content bg-no-repeat py-2 ltr:bg-[right_-1rem_center] rtl:bg-[left_-1rem_center] ltr:pr-6 rtl:pl-6 rtl:pr-3 ltr:pl-3; @apply field-base h-10 bg-origin-content focus-visible:outline-none bg-no-repeat py-2 ltr:bg-[right_-1rem_center] rtl:bg-[left_-1rem_center] ltr:pr-6 rtl:pl-6 rtl:pr-3 ltr:pl-3;
&[disabled] { &[disabled] {
@apply field-disabled; @apply field-disabled;
@@ -1,41 +0,0 @@
<script setup>
import ConfirmButton from './ConfirmButton.vue';
import { ref } from 'vue';
const count = ref(0);
const incrementCount = () => {
count.value += 1;
};
</script>
<template>
<Story
title="Components/ConfirmButton"
:layout="{ type: 'grid', width: '400px' }"
>
<Variant title="Basic">
<div class="grid gap-2 p-4 bg-white dark:bg-slate-900">
<p>{{ count }}</p>
<ConfirmButton
label="Delete"
confirm-label="Confirm?"
@click="incrementCount"
/>
</div>
</Variant>
<Variant title="Color Change">
<div class="grid gap-2 p-4 bg-white dark:bg-slate-900">
<p>{{ count }}</p>
<ConfirmButton
label="Archive"
confirm-label="Confirm?"
color="slate"
confirm-color="amber"
@click="incrementCount"
/>
</div>
</Variant>
</Story>
</template>
@@ -1,99 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
import Button from './Button.vue';
const props = defineProps({
label: { type: [String, Number], default: '' },
confirmLabel: { type: [String, Number], default: '' },
color: { type: String, default: 'blue' },
confirmColor: { type: String, default: 'ruby' },
confirmHint: { type: String, default: '' },
variant: { type: String, default: null },
size: { type: String, default: null },
justify: { type: String, default: null },
icon: { type: [String, Object, Function], default: '' },
trailingIcon: { type: Boolean, default: false },
isLoading: { type: Boolean, default: false },
});
const emit = defineEmits(['click']);
const isConfirmMode = ref(false);
const isClicked = ref(false);
const currentLabel = computed(() => {
return isConfirmMode.value ? props.confirmLabel : props.label;
});
const currentColor = computed(() => {
return isConfirmMode.value ? props.confirmColor : props.color;
});
const resetConfirmMode = () => {
isConfirmMode.value = false;
isClicked.value = false;
};
const handleClick = () => {
if (!isConfirmMode.value) {
isConfirmMode.value = true;
} else {
isClicked.value = true;
emit('click');
setTimeout(resetConfirmMode, 400);
}
};
</script>
<template>
<div
class="relative"
:class="{
'animate-bounce-complete': isClicked,
}"
>
<Button
type="button"
:label="currentLabel"
:color="currentColor"
:variant="variant"
:size="size"
:justify="justify"
:icon="icon"
:trailing-icon="trailingIcon"
:is-loading="isLoading"
@click="handleClick"
@blur="resetConfirmMode"
>
<template v-if="$slots.default" #default>
<slot />
</template>
<template v-if="$slots.icon" #icon>
<slot name="icon" />
</template>
</Button>
<div
v-if="isConfirmMode && confirmHint"
class="absolute mt-1 w-full text-[10px] text-center text-n-slate-10"
>
{{ confirmHint }}
</div>
</div>
</template>
<style scoped>
@keyframes bounce-complete {
0% {
transform: scale(0.95);
}
50% {
transform: scale(1.02);
}
100% {
transform: scale(1);
}
}
.animate-bounce-complete {
animation: bounce-complete 0.2s cubic-bezier(0.68, -0.55, 0.265, 1.55);
}
</style>
@@ -477,7 +477,7 @@ const menuItems = computed(() => {
<template> <template>
<aside <aside
class="w-[200px] bg-n-solid-2 rtl:border-l ltr:border-r border-n-weak h-screen flex flex-col text-sm pb-1" class="w-[12.5rem] bg-n-solid-2 rtl:border-l ltr:border-r border-n-weak h-screen flex flex-col text-sm pb-1"
> >
<section class="grid gap-2 mt-2 mb-4"> <section class="grid gap-2 mt-2 mb-4">
<div class="flex items-center min-w-0 gap-2 px-2"> <div class="flex items-center min-w-0 gap-2 px-2">
@@ -25,7 +25,10 @@ defineProps({
:username="user.name" :username="user.name"
:status="user.availability_status" :status="user.availability_status"
/> />
<span class="my-0 truncate text-capitalize" :class="textClass"> <span
class="my-0 overflow-hidden whitespace-nowrap text-ellipsis text-capitalize"
:class="textClass"
>
{{ user.name }} {{ user.name }}
</span> </span>
</div> </div>
@@ -99,6 +99,7 @@ export default {
<ConversationHeader <ConversationHeader
v-if="currentChat.id" v-if="currentChat.id"
:chat="currentChat" :chat="currentChat"
:is-inbox-view="isInboxView"
:show-back-button="isOnExpandedLayout && !isInboxView" :show-back-button="isOnExpandedLayout && !isInboxView"
/> />
<woot-tabs <woot-tabs
@@ -1,9 +1,7 @@
<script setup> <script>
import { computed, ref } from 'vue'; import { mapGetters } from 'vuex';
import { useRoute } from 'vue-router';
import { useStore } from 'vuex';
import { useElementSize } from '@vueuse/core';
import BackButton from '../BackButton.vue'; import BackButton from '../BackButton.vue';
import inboxMixin from 'shared/mixins/inboxMixin';
import InboxName from '../InboxName.vue'; import InboxName from '../InboxName.vue';
import MoreActions from './MoreActions.vue'; import MoreActions from './MoreActions.vue';
import Thumbnail from '../Thumbnail.vue'; import Thumbnail from '../Thumbnail.vue';
@@ -13,161 +11,176 @@ import { conversationListPageURL } from 'dashboard/helper/URLHelper';
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers'; import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
import { FEATURE_FLAGS } from 'dashboard/featureFlags'; import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import Linear from './linear/index.vue'; import Linear from './linear/index.vue';
import { useInbox } from 'dashboard/composables/useInbox';
import { useI18n } from 'vue-i18n';
const props = defineProps({ import NextButton from 'dashboard/components-next/button/Button.vue';
chat: {
type: Object, export default {
default: () => ({}), components: {
BackButton,
InboxName,
MoreActions,
Thumbnail,
SLACardLabel,
Linear,
NextButton,
}, },
showBackButton: { mixins: [inboxMixin],
type: Boolean, props: {
default: false, chat: {
type: Object,
default: () => ({}),
},
showBackButton: {
type: Boolean,
default: false,
},
isInboxView: {
type: Boolean,
default: false,
},
}, },
}); computed: {
...mapGetters({
const { t } = useI18n(); currentChat: 'getSelectedChat',
const store = useStore(); accountId: 'getCurrentAccountId',
const route = useRoute(); isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
const conversationHeader = ref(null); appIntegrations: 'integrations/getAppIntegrations',
const { width } = useElementSize(conversationHeader); }),
const { isAWebWidgetInbox } = useInbox(); chatMetadata() {
return this.chat.meta;
const currentChat = computed(() => store.getters.getSelectedChat); },
const accountId = computed(() => store.getters.getCurrentAccountId); backButtonUrl() {
const isFeatureEnabledonAccount = computed( const {
() => store.getters['accounts/isFeatureEnabledonAccount'] params: { accountId, inbox_id: inboxId, label, teamId },
); name,
const appIntegrations = computed( } = this.$route;
() => store.getters['integrations/getAppIntegrations'] return conversationListPageURL({
); accountId,
inboxId,
const chatMetadata = computed(() => props.chat.meta); label,
teamId,
const backButtonUrl = computed(() => { conversationType: name === 'conversation_mentions' ? 'mention' : '',
const { });
params: { inbox_id: inboxId, label, teamId }, },
name, isHMACVerified() {
} = route; if (!this.isAWebWidgetInbox) {
return conversationListPageURL({ return true;
accountId, }
inboxId, return this.chatMetadata.hmac_verified;
label, },
teamId, currentContact() {
conversationType: name === 'conversation_mentions' ? 'mention' : '', return this.$store.getters['contacts/getContact'](
}); this.chat.meta.sender.id
}); );
},
const isHMACVerified = computed(() => { isSnoozed() {
if (!isAWebWidgetInbox.value) { return this.currentChat.status === wootConstants.STATUS_TYPE.SNOOZED;
return true; },
} snoozedDisplayText() {
return chatMetadata.value.hmac_verified; const { snoozed_until: snoozedUntil } = this.currentChat;
}); if (snoozedUntil) {
return `${this.$t(
const currentContact = computed(() => 'CONVERSATION.HEADER.SNOOZED_UNTIL'
store.getters['contacts/getContact'](props.chat.meta.sender.id) )} ${snoozedReopenTime(snoozedUntil)}`;
); }
return this.$t('CONVERSATION.HEADER.SNOOZED_UNTIL_NEXT_REPLY');
const isSnoozed = computed( },
() => currentChat.value.status === wootConstants.STATUS_TYPE.SNOOZED inbox() {
); const { inbox_id: inboxId } = this.chat;
return this.$store.getters['inboxes/getInbox'](inboxId);
const snoozedDisplayText = computed(() => { },
const { snoozed_until: snoozedUntil } = currentChat.value; hasMultipleInboxes() {
if (snoozedUntil) { return this.$store.getters['inboxes/getInboxes'].length > 1;
return `${t('CONVERSATION.HEADER.SNOOZED_UNTIL')} ${snoozedReopenTime(snoozedUntil)}`; },
} hasSlaPolicyId() {
return t('CONVERSATION.HEADER.SNOOZED_UNTIL_NEXT_REPLY'); return this.chat?.sla_policy_id;
}); },
isLinearIntegrationEnabled() {
const inbox = computed(() => { return this.appIntegrations.find(
const { inbox_id: inboxId } = props.chat; integration => integration.id === 'linear' && !!integration.hooks.length
return store.getters['inboxes/getInbox'](inboxId); );
}); },
isLinearFeatureEnabled() {
const hasMultipleInboxes = computed( return this.isFeatureEnabledonAccount(
() => store.getters['inboxes/getInboxes'].length > 1 this.accountId,
); FEATURE_FLAGS.LINEAR
);
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id); },
},
const isLinearIntegrationEnabled = computed(() => };
appIntegrations.value.find(
integration => integration.id === 'linear' && !!integration.hooks.length
)
);
const isLinearFeatureEnabled = computed(() =>
isFeatureEnabledonAccount.value(accountId.value, FEATURE_FLAGS.LINEAR)
);
</script> </script>
<template> <template>
<div <div
ref="conversationHeader" class="flex flex-col items-center justify-between px-3 py-2 border-b bg-n-background border-n-weak md:flex-row h-12"
class="flex flex-col items-center justify-center flex-1 w-full min-w-0 xl:flex-row px-3 py-2 border-b bg-n-background border-n-weak h-24 xl:h-12"
> >
<div <div
class="flex items-center justify-start w-full xl:w-auto max-w-full min-w-0" class="flex flex-col items-center justify-center flex-1 w-full min-w-0"
:class="isInboxView ? 'sm:flex-row' : 'md:flex-row'"
> >
<BackButton <div class="flex items-center justify-start max-w-full min-w-0 w-fit">
v-if="showBackButton" <BackButton
:back-url="backButtonUrl" v-if="showBackButton"
class="ltr:mr-2 rtl:ml-2" :back-url="backButtonUrl"
/> class="ltr:mr-2 rtl:ml-2"
<Thumbnail />
:src="currentContact.thumbnail" <Thumbnail
:username="currentContact.name" :src="currentContact.thumbnail"
:status="currentContact.availability_status" :badge="inboxBadge"
size="32px" :username="currentContact.name"
/> :status="currentContact.availability_status"
<div size="32px"
class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2 w-fit" />
>
<div class="flex flex-row items-center max-w-full gap-1 p-0 m-0 w-fit">
<span
class="text-sm font-medium truncate leading-tight text-n-slate-12"
>
{{ currentContact.name }}
</span>
<fluent-icon
v-if="!isHMACVerified"
v-tooltip="$t('CONVERSATION.UNVERIFIED_SESSION')"
size="14"
class="text-n-amber-10 my-0 mx-0 min-w-[14px]"
icon="warning"
/>
</div>
<div <div
class="flex items-center gap-2 overflow-hidden text-xs conversation--header--actions text-ellipsis whitespace-nowrap" class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2 w-fit"
> >
<InboxName v-if="hasMultipleInboxes" :inbox="inbox" class="!mx-0" /> <div
<span v-if="isSnoozed" class="font-medium text-n-amber-10"> class="flex flex-row items-center max-w-full gap-1 p-0 m-0 w-fit"
{{ snoozedDisplayText }} >
</span> <NextButton link slate>
<span
class="text-sm font-medium truncate leading-tight text-n-slate-12"
>
{{ currentContact.name }}
</span>
</NextButton>
<fluent-icon
v-if="!isHMACVerified"
v-tooltip="$t('CONVERSATION.UNVERIFIED_SESSION')"
size="14"
class="text-n-amber-10 my-0 mx-0 min-w-[14px]"
icon="warning"
/>
</div>
<div
class="flex items-center gap-2 overflow-hidden text-xs conversation--header--actions text-ellipsis whitespace-nowrap"
>
<InboxName v-if="hasMultipleInboxes" :inbox="inbox" />
<span v-if="isSnoozed" class="font-medium text-n-amber-10">
{{ snoozedDisplayText }}
</span>
</div>
</div> </div>
</div> </div>
</div> <div
<div class="flex flex-row items-center justify-end flex-grow gap-2 mt-3 header-actions-wrap lg:mt-0"
class="flex flex-row items-center justify-start xl:justify-end flex-grow gap-2 w-full xl:w-auto mt-3 header-actions-wrap xl:mt-0" >
> <SLACardLabel v-if="hasSlaPolicyId" :chat="chat" show-extended-info />
<SLACardLabel <Linear
v-if="hasSlaPolicyId" v-if="isLinearIntegrationEnabled && isLinearFeatureEnabled"
:chat="chat" :conversation-id="currentChat.id"
show-extended-info />
:parent-width="width" <MoreActions :conversation-id="currentChat.id" />
class="hidden lg:flex" </div>
/>
<Linear
v-if="isLinearIntegrationEnabled && isLinearFeatureEnabled"
:conversation-id="currentChat.id"
:parent-width="width"
class="hidden lg:flex"
/>
<MoreActions :conversation-id="currentChat.id" />
</div> </div>
</div> </div>
</template> </template>
<style lang="scss" scoped>
.conversation--header--actions {
::v-deep .inbox--name {
@apply m-0;
}
}
</style>
@@ -44,7 +44,7 @@ const activeTab = computed(() => {
<template> <template>
<div <div
class="ltr:border-l rtl:border-r border-n-weak h-full overflow-hidden z-10 w-[320px] min-w-[320px] 2xl:min-w-[360px] 2xl:w-[360px] flex flex-col bg-n-background" class="ltr:border-l rtl:border-r border-n-weak h-full overflow-hidden z-10 w-80 min-w-80 2xl:min-w-96 2xl:w-96 flex flex-col bg-n-background"
> >
<div class="flex flex-1 overflow-auto"> <div class="flex flex-1 overflow-auto">
<ContactPanel <ContactPanel
@@ -1,14 +1,10 @@
<script setup> <script>
import { computed, onUnmounted } from 'vue'; import { mapGetters } from 'vuex';
import { useToggle } from '@vueuse/core';
import { useStore } from 'vuex';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { emitter } from 'shared/helpers/mitt'; import { emitter } from 'shared/helpers/mitt';
import EmailTranscriptModal from './EmailTranscriptModal.vue'; import EmailTranscriptModal from './EmailTranscriptModal.vue';
import ResolveAction from '../../buttons/ResolveAction.vue'; import ResolveAction from '../../buttons/ResolveAction.vue';
import ButtonV4 from 'dashboard/components-next/button/Button.vue'; import ButtonV4 from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import { import {
CMD_MUTE_CONVERSATION, CMD_MUTE_CONVERSATION,
@@ -16,111 +12,97 @@ import {
CMD_UNMUTE_CONVERSATION, CMD_UNMUTE_CONVERSATION,
} from 'dashboard/helper/commandbar/events'; } from 'dashboard/helper/commandbar/events';
// No props needed as we're getting currentChat from the store directly export default {
const store = useStore(); components: {
const { t } = useI18n(); EmailTranscriptModal,
ResolveAction,
const [showEmailActionsModal, toggleEmailModal] = useToggle(false); ButtonV4,
const [showActionsDropdown, toggleDropdown] = useToggle(false); },
data() {
const currentChat = computed(() => store.getters.getSelectedChat); return {
showEmailActionsModal: false,
const actionMenuItems = computed(() => { };
const items = []; },
computed: {
if (!currentChat.value.muted) { ...mapGetters({ currentChat: 'getSelectedChat' }),
items.push({ },
icon: 'i-lucide-volume-off', mounted() {
label: t('CONTACT_PANEL.MUTE_CONTACT'), emitter.on(CMD_MUTE_CONVERSATION, this.mute);
action: 'mute', emitter.on(CMD_UNMUTE_CONVERSATION, this.unmute);
value: 'mute', emitter.on(CMD_SEND_TRANSCRIPT, this.toggleEmailActionsModal);
}); },
} else { unmounted() {
items.push({ emitter.off(CMD_MUTE_CONVERSATION, this.mute);
icon: 'i-lucide-volume-1', emitter.off(CMD_UNMUTE_CONVERSATION, this.unmute);
label: t('CONTACT_PANEL.UNMUTE_CONTACT'), emitter.off(CMD_SEND_TRANSCRIPT, this.toggleEmailActionsModal);
action: 'unmute', },
value: 'unmute', methods: {
}); mute() {
} this.$store.dispatch('muteConversation', this.currentChat.id);
useAlert(this.$t('CONTACT_PANEL.MUTED_SUCCESS'));
items.push({ },
icon: 'i-lucide-share', unmute() {
label: t('CONTACT_PANEL.SEND_TRANSCRIPT'), this.$store.dispatch('unmuteConversation', this.currentChat.id);
action: 'send_transcript', useAlert(this.$t('CONTACT_PANEL.UNMUTED_SUCCESS'));
value: 'send_transcript', },
}); toggleEmailActionsModal() {
this.showEmailActionsModal = !this.showEmailActionsModal;
return items; },
}); },
const handleActionClick = ({ action }) => {
toggleDropdown(false);
if (action === 'mute') {
store.dispatch('muteConversation', currentChat.value.id);
useAlert(t('CONTACT_PANEL.MUTED_SUCCESS'));
} else if (action === 'unmute') {
store.dispatch('unmuteConversation', currentChat.value.id);
useAlert(t('CONTACT_PANEL.UNMUTED_SUCCESS'));
} else if (action === 'send_transcript') {
toggleEmailModal();
}
}; };
// These functions are needed for the event listeners
const mute = () => {
store.dispatch('muteConversation', currentChat.value.id);
useAlert(t('CONTACT_PANEL.MUTED_SUCCESS'));
};
const unmute = () => {
store.dispatch('unmuteConversation', currentChat.value.id);
useAlert(t('CONTACT_PANEL.UNMUTED_SUCCESS'));
};
emitter.on(CMD_MUTE_CONVERSATION, mute);
emitter.on(CMD_UNMUTE_CONVERSATION, unmute);
emitter.on(CMD_SEND_TRANSCRIPT, toggleEmailModal);
onUnmounted(() => {
emitter.off(CMD_MUTE_CONVERSATION, mute);
emitter.off(CMD_UNMUTE_CONVERSATION, unmute);
emitter.off(CMD_SEND_TRANSCRIPT, toggleEmailModal);
});
</script> </script>
<template> <template>
<div class="relative flex items-center gap-2 actions--container"> <div class="relative flex items-center gap-2 actions--container">
<ButtonV4
v-if="!currentChat.muted"
v-tooltip="$t('CONTACT_PANEL.MUTE_CONTACT')"
size="sm"
variant="ghost"
color="slate"
icon="i-lucide-volume-off"
@click="mute"
/>
<ButtonV4
v-else
v-tooltip.left="$t('CONTACT_PANEL.UNMUTE_CONTACT')"
size="sm"
variant="ghost"
color="slate"
icon="i-lucide-volume-1"
@click="unmute"
/>
<ButtonV4
v-tooltip="$t('CONTACT_PANEL.SEND_TRANSCRIPT')"
size="sm"
variant="ghost"
color="slate"
icon="i-lucide-share"
@click="toggleEmailActionsModal"
/>
<ResolveAction <ResolveAction
:conversation-id="currentChat.id" :conversation-id="currentChat.id"
:status="currentChat.status" :status="currentChat.status"
/> />
<div
v-on-clickaway="() => toggleDropdown(false)"
class="relative flex items-center group"
>
<ButtonV4
v-tooltip="$t('CONVERSATION.HEADER.MORE_ACTIONS')"
size="sm"
variant="ghost"
color="slate"
icon="i-lucide-more-vertical"
class="rounded-md group-hover:bg-n-alpha-2"
@click="toggleDropdown()"
/>
<DropdownMenu
v-if="showActionsDropdown"
:menu-items="actionMenuItems"
class="mt-1 ltr:right-0 rtl:left-0 top-full"
@action="handleActionClick"
/>
</div>
<EmailTranscriptModal <EmailTranscriptModal
v-if="showEmailActionsModal" v-if="showEmailActionsModal"
:show="showEmailActionsModal" :show="showEmailActionsModal"
:current-chat="currentChat" :current-chat="currentChat"
@cancel="toggleEmailModal" @cancel="toggleEmailActionsModal"
/> />
</div> </div>
</template> </template>
<style scoped lang="scss">
.more--button {
@apply items-center flex ml-2 rtl:ml-0 rtl:mr-2;
}
.dropdown-pane {
@apply -right-2 top-12;
}
.icon {
@apply mr-1 rtl:mr-0 rtl:ml-1 min-w-[1rem];
}
</style>
@@ -1,115 +1,132 @@
<script setup> <script>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { evaluateSLAStatus } from '@chatwoot/utils'; import { evaluateSLAStatus } from '@chatwoot/utils';
import SLAPopoverCard from './SLAPopoverCard.vue'; import SLAPopoverCard from './SLAPopoverCard.vue';
const props = defineProps({
chat: {
type: Object,
default: () => ({}),
},
showExtendedInfo: {
type: Boolean,
default: false,
},
parentWidth: {
type: Number,
default: 1000,
},
});
const REFRESH_INTERVAL = 60000; const REFRESH_INTERVAL = 60000;
const { t } = useI18n();
const timer = ref(null); export default {
const slaStatus = ref({ components: {
threshold: null, SLAPopoverCard,
isSlaMissed: false, },
type: null, props: {
icon: null, chat: {
}); type: Object,
default: () => ({}),
},
showExtendedInfo: {
type: Boolean,
default: false,
},
},
data() {
return {
timer: null,
showSlaPopover: false,
slaStatus: {
threshold: null,
isSlaMissed: false,
type: null,
icon: null,
},
};
},
computed: {
slaPolicyId() {
return this.chat?.sla_policy_id;
},
appliedSLA() {
return this.chat?.applied_sla;
},
slaEvents() {
return this.chat?.sla_events;
},
hasSlaThreshold() {
return this.slaStatus?.threshold;
},
isSlaMissed() {
return this.slaStatus?.isSlaMissed;
},
slaTextStyles() {
return this.isSlaMissed ? 'text-n-ruby-11' : 'text-n-amber-11';
},
slaStatusText() {
const upperCaseType = this.slaStatus?.type?.toUpperCase(); // FRT, NRT, or RT
const statusKey = this.isSlaMissed ? 'MISSED' : 'DUE';
const appliedSLA = computed(() => props.chat?.applied_sla); return this.$t(`CONVERSATION.HEADER.SLA_STATUS.${upperCaseType}`, {
const slaEvents = computed(() => props.chat?.sla_events); status: this.$t(`CONVERSATION.HEADER.SLA_STATUS.${statusKey}`),
const hasSlaThreshold = computed(() => slaStatus.value?.threshold); });
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed); },
const slaTextStyles = computed(() => showSlaPopoverCard() {
isSlaMissed.value ? 'text-n-ruby-11' : 'text-n-amber-11' return (
); this.showExtendedInfo && this.showSlaPopover && this.slaEvents.length
);
const slaStatusText = computed(() => { },
const upperCaseType = slaStatus.value?.type?.toUpperCase(); // FRT, NRT, or RT },
const statusKey = isSlaMissed.value ? 'MISSED' : 'DUE'; watch: {
chat() {
return t(`CONVERSATION.HEADER.SLA_STATUS.${upperCaseType}`, { this.updateSlaStatus();
status: t(`CONVERSATION.HEADER.SLA_STATUS.${statusKey}`), },
}); },
}); mounted() {
this.updateSlaStatus();
const showSlaPopoverCard = computed( this.createTimer();
() => props.showExtendedInfo && slaEvents.value?.length > 0 },
); unmounted() {
if (this.timer) {
const groupClass = computed(() => { clearTimeout(this.timer);
return props.showExtendedInfo }
? 'h-[26px] rounded-lg bg-n-alpha-1' },
: 'rounded h-5 border border-n-strong'; methods: {
}); createTimer() {
this.timer = setTimeout(() => {
const updateSlaStatus = () => { this.updateSlaStatus();
slaStatus.value = evaluateSLAStatus({ this.createTimer();
appliedSla: appliedSLA.value, }, REFRESH_INTERVAL);
chat: props.chat, },
}); updateSlaStatus() {
this.slaStatus = evaluateSLAStatus({
appliedSla: this.appliedSLA,
chat: this.chat,
});
},
openSlaPopover() {
if (!this.showExtendedInfo) return;
this.showSlaPopover = true;
},
closeSlaPopover() {
this.showSlaPopover = false;
},
},
}; };
const createTimer = () => {
timer.value = setTimeout(() => {
updateSlaStatus();
createTimer();
}, REFRESH_INTERVAL);
};
watch(
() => props.chat,
() => {
updateSlaStatus();
}
);
const slaPopoverClass = computed(() => {
return props.showExtendedInfo
? 'ltr:pr-1.5 rtl:pl-1.5 ltr:border-r rtl:border-l border-n-strong'
: '';
});
onMounted(() => {
updateSlaStatus();
createTimer();
});
onUnmounted(() => {
if (timer.value) {
clearTimeout(timer.value);
}
});
</script> </script>
<!-- eslint-disable-next-line vue/no-root-v-if --> <!-- eslint-disable-next-line vue/no-root-v-if -->
<template> <template>
<div <div
v-if="hasSlaThreshold" v-if="hasSlaThreshold"
class="relative flex items-center cursor-pointer min-w-fit group" class="relative flex items-center cursor-pointer min-w-fit"
:class="groupClass" :class="
showExtendedInfo
? 'h-[26px] rounded-lg bg-n-alpha-1'
: 'rounded h-5 border border-n-strong'
"
> >
<div <div
class="flex items-center w-full truncate px-1.5" v-on-clickaway="closeSlaPopover"
:class="showExtendedInfo ? '' : 'gap-1'" class="flex items-center w-full truncate"
:class="showExtendedInfo ? 'px-1.5' : 'px-2 gap-1'"
@mouseover="openSlaPopover()"
> >
<div class="flex items-center gap-1" :class="slaPopoverClass"> <div
class="flex items-center gap-1"
:class="
showExtendedInfo &&
'ltr:pr-1.5 rtl:pl-1.5 ltr:border-r rtl:border-l border-n-strong'
"
>
<fluent-icon <fluent-icon
size="12" size="14"
:icon="slaStatus.icon" :icon="slaStatus.icon"
type="outline" type="outline"
:icon-lib="isSlaMissed ? 'lucide' : 'fluent'" :icon-lib="isSlaMissed ? 'lucide' : 'fluent'"
@@ -117,7 +134,7 @@ onUnmounted(() => {
:class="slaTextStyles" :class="slaTextStyles"
/> />
<span <span
v-if="showExtendedInfo && parentWidth > 650" v-if="showExtendedInfo"
class="text-xs font-medium" class="text-xs font-medium"
:class="slaTextStyles" :class="slaTextStyles"
> >
@@ -134,7 +151,7 @@ onUnmounted(() => {
<SLAPopoverCard <SLAPopoverCard
v-if="showSlaPopoverCard" v-if="showSlaPopoverCard"
:sla-missed-events="slaEvents" :sla-missed-events="slaEvents"
class="rtl:left-0 ltr:right-0 top-7 hidden group-hover:flex" class="right-0 top-7"
/> />
</div> </div>
</template> </template>
@@ -16,10 +16,6 @@ const props = defineProps({
type: [Number, String], type: [Number, String],
required: true, required: true,
}, },
parentWidth: {
type: Number,
default: 10000,
},
}); });
defineOptions({ defineOptions({
@@ -77,14 +73,6 @@ const unlinkIssue = async linkId => {
} }
}; };
const shouldShowIssueIdentifier = computed(() => {
if (!linkedIssue.value) {
return false;
}
return props.parentWidth > 600;
});
const openIssue = () => { const openIssue = () => {
if (!linkedIssue.value) shouldShowPopup.value = true; if (!linkedIssue.value) shouldShowPopup.value = true;
shouldShow.value = true; shouldShow.value = true;
@@ -131,10 +119,7 @@ onMounted(() => {
class="text-[#5E6AD2] flex-shrink-0" class="text-[#5E6AD2] flex-shrink-0"
view-box="0 0 19 19" view-box="0 0 19 19"
/> />
<span <span v-if="linkedIssue" class="text-xs font-medium text-n-slate-11">
v-if="shouldShowIssueIdentifier"
class="text-xs font-medium text-n-slate-11"
>
{{ linkedIssue.issue.identifier }} {{ linkedIssue.issue.identifier }}
</span> </span>
</Button> </Button>
@@ -142,7 +127,7 @@ onMounted(() => {
v-if="linkedIssue" v-if="linkedIssue"
:issue="linkedIssue.issue" :issue="linkedIssue.issue"
:link-id="linkedIssue.id" :link-id="linkedIssue.id"
class="absolute rtl:left-0 ltr:right-0 top-9 invisible group-hover:visible" class="absolute right-0 top-[36px] invisible group-hover:visible"
@unlink-issue="unlinkIssue" @unlink-issue="unlinkIssue"
/> />
<woot-modal <woot-modal
@@ -0,0 +1,110 @@
import { mount } from '@vue/test-utils';
import { createStore } from 'vuex';
import MoreActions from '../MoreActions.vue';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue';
vi.mock('shared/helpers/mitt', () => ({
emitter: {
emit: vi.fn(),
on: vi.fn(),
off: vi.fn(),
},
}));
const mockDirective = {
mounted: () => {},
};
import { emitter } from 'shared/helpers/mitt';
describe('MoveActions', () => {
let currentChat = { id: 8, muted: false };
let store = null;
let muteConversation = null;
let unmuteConversation = null;
beforeEach(() => {
muteConversation = vi.fn(() => Promise.resolve());
unmuteConversation = vi.fn(() => Promise.resolve());
store = createStore({
state: {
authenticated: true,
currentChat,
},
getters: {
getSelectedChat: () => currentChat,
},
modules: {
conversations: {
namespaced: false,
actions: { muteConversation, unmuteConversation },
},
},
});
});
const createWrapper = () =>
mount(MoreActions, {
global: {
plugins: [store],
components: {
'fluent-icon': FluentIcon,
},
directives: {
'on-clickaway': mockDirective,
},
},
});
describe('muting discussion', () => {
it('triggers "muteConversation"', async () => {
const wrapper = createWrapper();
await wrapper.find('button:first-child').trigger('click');
expect(muteConversation).toHaveBeenCalledTimes(1);
expect(muteConversation).toHaveBeenCalledWith(
expect.any(Object), // First argument is the Vuex context object
currentChat.id // Second argument is the ID of the conversation
);
});
it('shows alert', async () => {
const wrapper = createWrapper();
await wrapper.find('button:first-child').trigger('click');
expect(emitter.emit).toBeCalledWith('newToastMessage', {
message:
'This contact is blocked successfully. You will not be notified of any future conversations.',
action: null,
});
});
});
describe('unmuting discussion', () => {
beforeEach(() => {
currentChat.muted = true;
});
it('triggers "unmuteConversation"', async () => {
const wrapper = createWrapper();
await wrapper.find('button:first-child').trigger('click');
expect(unmuteConversation).toHaveBeenCalledTimes(1);
expect(unmuteConversation).toHaveBeenCalledWith(
expect.any(Object), // First argument is the Vuex context object
currentChat.id // Second argument is the ID of the conversation
);
});
it('shows alert', async () => {
const wrapper = createWrapper();
await wrapper.find('button:first-child').trigger('click');
expect(emitter.emit).toBeCalledWith('newToastMessage', {
message: 'This contact is unblocked successfully.',
action: null,
});
});
});
});
@@ -43,7 +43,7 @@ describe('useFontSize', () => {
it('returns fontSizeOptions with correct structure', () => { it('returns fontSizeOptions with correct structure', () => {
const { fontSizeOptions } = useFontSize(); const { fontSizeOptions } = useFontSize();
expect(fontSizeOptions).toHaveLength(5); expect(fontSizeOptions).toHaveLength(6);
expect(fontSizeOptions[0]).toHaveProperty('value'); expect(fontSizeOptions[0]).toHaveProperty('value');
expect(fontSizeOptions[0]).toHaveProperty('label'); expect(fontSizeOptions[0]).toHaveProperty('label');
@@ -59,6 +59,12 @@ describe('useFontSize', () => {
label: label:
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.SMALLER', 'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.SMALLER',
}); });
expect(fontSizeOptions.find(option => option.value === '22px')).toEqual({
value: '22px',
label:
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.EXTRA_LARGE',
});
}); });
it('returns currentFontSize from UI settings', () => { it('returns currentFontSize from UI settings', () => {
@@ -78,6 +84,9 @@ describe('useFontSize', () => {
applyFontSize('14px'); applyFontSize('14px');
expect(document.documentElement.style.fontSize).toBe('14px'); expect(document.documentElement.style.fontSize).toBe('14px');
applyFontSize('22px');
expect(document.documentElement.style.fontSize).toBe('22px');
applyFontSize('16px'); applyFontSize('16px');
expect(document.documentElement.style.fontSize).toBe('16px'); expect(document.documentElement.style.fontSize).toBe('16px');
}); });
@@ -136,6 +145,8 @@ describe('useFontSize', () => {
'Smaller', 'Smaller',
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.DEFAULT': 'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.DEFAULT':
'Default', 'Default',
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.EXTRA_LARGE':
'Extra Large',
}; };
return translations[key] || key; return translations[key] || key;
}); });
@@ -149,6 +160,9 @@ describe('useFontSize', () => {
expect(fontSizeOptions.find(option => option.value === '16px').label).toBe( expect(fontSizeOptions.find(option => option.value === '16px').label).toBe(
'Default' 'Default'
); );
expect(fontSizeOptions.find(option => option.value === '22px').label).toBe(
'Extra Large'
);
// Verify translation function was called with correct keys // Verify translation function was called with correct keys
expect(mockTranslate).toHaveBeenCalledWith( expect(mockTranslate).toHaveBeenCalledWith(
@@ -19,6 +19,7 @@ const FONT_SIZE_OPTIONS = {
DEFAULT: '16px', DEFAULT: '16px',
LARGE: '18px', LARGE: '18px',
LARGER: '20px', LARGER: '20px',
EXTRA_LARGE: '22px',
}; };
/** /**
@@ -129,7 +129,6 @@ export function usePolicy() {
return { return {
checkPermissions, checkPermissions,
shouldShowPaywall, shouldShowPaywall,
isFeatureFlagEnabled,
shouldShow, shouldShow,
}; };
} }
@@ -62,9 +62,7 @@
"ACCESS_TOKEN": { "ACCESS_TOKEN": {
"TITLE": "Access Token", "TITLE": "Access Token",
"DESCRIPTION": "Copy the access token and save it securely", "DESCRIPTION": "Copy the access token and save it securely",
"COPY_SUCCESSFUL": "Access token copied to clipboard", "COPY_SUCCESSFUL": "Access token copied to clipboard"
"RESET_SUCCESS": "Access token regenerated successfully",
"RESET_ERROR": "Unable to regenerate access token. Please try again"
}, },
"FORM": { "FORM": {
"AVATAR": { "AVATAR": {
@@ -70,7 +70,6 @@
"RESOLVE_ACTION": "Resolve", "RESOLVE_ACTION": "Resolve",
"REOPEN_ACTION": "Reopen", "REOPEN_ACTION": "Reopen",
"OPEN_ACTION": "Open", "OPEN_ACTION": "Open",
"MORE_ACTIONS": "More actions",
"OPEN": "More", "OPEN": "More",
"CLOSE": "Close", "CLOSE": "Close",
"DETAILS": "details", "DETAILS": "details",
@@ -76,12 +76,7 @@
"ACCESS_TOKEN": { "ACCESS_TOKEN": {
"TITLE": "Access Token", "TITLE": "Access Token",
"NOTE": "This token can be used if you are building an API based integration", "NOTE": "This token can be used if you are building an API based integration",
"COPY": "Copy", "COPY": "Copy"
"RESET": "Reset",
"CONFIRM_RESET": "Are you sure?",
"CONFIRM_HINT": "Click again to confirm",
"RESET_SUCCESS": "Access token regenerated successfully",
"RESET_ERROR": "Unable to regenerate access token. Please try again"
}, },
"AUDIO_NOTIFICATIONS_SECTION": { "AUDIO_NOTIFICATIONS_SECTION": {
"TITLE": "Audio Alerts", "TITLE": "Audio Alerts",
@@ -10,8 +10,10 @@ import {
CONTACT_PERMISSIONS, CONTACT_PERMISSIONS,
PORTAL_PERMISSIONS, PORTAL_PERMISSIONS,
} from 'dashboard/constants/permissions.js'; } from 'dashboard/constants/permissions.js';
import { usePolicy } from 'dashboard/composables/usePolicy'; import {
import { FEATURE_FLAGS } from 'dashboard/featureFlags'; getUserPermissions,
filterItemsByPermission,
} from 'dashboard/helper/permissionsHelper.js';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events'; import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
import Policy from 'dashboard/components/policy.vue'; import Policy from 'dashboard/components/policy.vue';
@@ -37,6 +39,8 @@ const pages = ref({
articles: 1, articles: 1,
}); });
const currentUser = useMapGetter('getCurrentUser');
const currentAccountId = useMapGetter('getCurrentAccountId');
const contactRecords = useMapGetter('conversationSearch/getContactRecords'); const contactRecords = useMapGetter('conversationSearch/getContactRecords');
const conversationRecords = useMapGetter( const conversationRecords = useMapGetter(
'conversationSearch/getConversationRecords' 'conversationSearch/getConversationRecords'
@@ -79,7 +83,9 @@ const filterConversations = filterByTab('conversations');
const filterMessages = filterByTab('messages'); const filterMessages = filterByTab('messages');
const filterArticles = filterByTab('articles'); const filterArticles = filterByTab('articles');
const { shouldShow, isFeatureFlagEnabled } = usePolicy(); const userPermissions = computed(() =>
getUserPermissions(currentUser.value, currentAccountId.value)
);
const TABS_CONFIG = { const TABS_CONFIG = {
all: { all: {
@@ -105,67 +111,47 @@ const TABS_CONFIG = {
}, },
articles: { articles: {
permissions: [...ROLES, PORTAL_PERMISSIONS], permissions: [...ROLES, PORTAL_PERMISSIONS],
featureFlag: FEATURE_FLAGS.HELP_CENTER,
count: () => mappedArticles.value.length, count: () => mappedArticles.value.length,
}, },
}; };
const tabs = computed(() => { const tabs = computed(() => {
return Object.entries(TABS_CONFIG) const configs = Object.entries(TABS_CONFIG).map(([key, config]) => ({
.map(([key, config]) => ({ key,
key, name: t(`SEARCH.TABS.${key.toUpperCase()}`),
name: t(`SEARCH.TABS.${key.toUpperCase()}`), count: config.count(),
count: config.count(), showBadge: key !== 'all',
showBadge: key !== 'all', permissions: config.permissions,
permissions: config.permissions, }));
featureFlag: config.featureFlag,
})) return filterItemsByPermission(
.filter(config => { configs,
// why the double check, glad you asked. userPermissions.value,
// Some features are marked as premium features, that means item => item.permissions
// the feature will be visible, but a Paywall will be shown instead );
// this works for pages and routes, but fails for UI elements like search here
// so we explicitly check if the feature is enabled
return (
shouldShow(config.featureFlag, config.permissions, null) &&
isFeatureFlagEnabled(config.featureFlag)
);
});
}); });
const totalSearchResultsCount = computed(() => { const totalSearchResultsCount = computed(() => {
const permissionCounts = [ const permissionCounts = {
{ contacts: {
permissions: [...ROLES, CONTACT_PERMISSIONS], permissions: [...ROLES, CONTACT_PERMISSIONS],
count: () => contacts.value.length, count: () => contacts.value.length,
}, },
{ conversations: {
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS], permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
count: () => conversations.value.length + messages.value.length, count: () => conversations.value.length + messages.value.length,
}, },
{ articles: {
permissions: [...ROLES, PORTAL_PERMISSIONS], permissions: [...ROLES, PORTAL_PERMISSIONS],
featureFlag: FEATURE_FLAGS.HELP_CENTER,
count: () => articles.value.length, count: () => articles.value.length,
}, },
]; };
return filterItemsByPermission(
return permissionCounts permissionCounts,
.filter(config => { userPermissions.value,
// why the double check, glad you asked. item => item.permissions,
// Some features are marked as premium features, that means (_, item) => item.count
// the feature will be visible, but a Paywall will be shown instead ).reduce((total, count) => total + count(), 0);
// this works for pages and routes, but fails for UI elements like search here
// so we explicitly check if the feature is enabled
return (
shouldShow(config.featureFlag, config.permissions, null) &&
isFeatureFlagEnabled(config.featureFlag)
);
})
.map(config => {
return config.count();
})
.reduce((sum, count) => sum + count, 0);
}); });
const activeTabIndex = computed(() => { const activeTabIndex = computed(() => {
@@ -369,9 +355,7 @@ onUnmounted(() => {
</Policy> </Policy>
<Policy <Policy
v-if="isFeatureFlagEnabled(FEATURE_FLAGS.HELP_CENTER)"
:permissions="[...ROLES, PORTAL_PERMISSIONS]" :permissions="[...ROLES, PORTAL_PERMISSIONS]"
:feature-flag="FEATURE_FLAGS.HELP_CENTER"
class="flex flex-col justify-center" class="flex flex-col justify-center"
> >
<SearchResultArticlesList <SearchResultArticlesList
@@ -24,7 +24,7 @@ const PortalsSettingsIndexPage = () =>
const meta = { const meta = {
featureFlag: FEATURE_FLAGS.HELP_CENTER, featureFlag: FEATURE_FLAGS.HELP_CENTER,
permissions: ['administrator', 'agent', 'knowledge_base_manage'], permissions: ['administrator', 'knowledge_base_manage'],
}; };
const portalRoutes = [ const portalRoutes = [
{ {
@@ -85,19 +85,13 @@ const portalRoutes = [
{ {
path: getPortalRoute('new'), path: getPortalRoute('new'),
name: 'portals_new', name: 'portals_new',
meta: { meta,
featureFlag: FEATURE_FLAGS.HELP_CENTER,
permissions: ['administrator', 'knowledge_base_manage'],
},
component: PortalsNew, component: PortalsNew,
}, },
{ {
path: getPortalRoute(':navigationPath'), path: getPortalRoute(':navigationPath'),
name: 'portals_index', name: 'portals_index',
meta: { meta,
featureFlag: FEATURE_FLAGS.HELP_CENTER,
permissions: ['administrator', 'knowledge_base_manage'],
},
component: PortalsIndex, component: PortalsIndex,
}, },
]; ];
@@ -228,20 +228,7 @@ const initializeForm = () => {
const onCopyToken = async value => { const onCopyToken = async value => {
await copyTextToClipboard(value); await copyTextToClipboard(value);
useAlert(t('AGENT_BOTS.ACCESS_TOKEN.COPY_SUCCESSFUL')); useAlert(t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
};
const onResetToken = async () => {
const response = await store.dispatch(
'agentBots/resetAccessToken',
props.selectedBot.id
);
if (response) {
accessToken.value = response.access_token;
useAlert(t('AGENT_BOTS.ACCESS_TOKEN.RESET_SUCCESS'));
} else {
useAlert(t('AGENT_BOTS.ACCESS_TOKEN.RESET_ERROR'));
}
}; };
const closeModal = () => { const closeModal = () => {
@@ -325,18 +312,7 @@ defineExpose({ dialogRef });
> >
{{ $t('AGENT_BOTS.ACCESS_TOKEN.TITLE') }} {{ $t('AGENT_BOTS.ACCESS_TOKEN.TITLE') }}
</label> </label>
<AccessToken <AccessToken :value="accessToken" @on-copy="onCopyToken" />
v-if="type === MODAL_TYPES.EDIT"
:value="accessToken"
@on-copy="onCopyToken"
@on-reset="onResetToken"
/>
<AccessToken
v-else
:value="accessToken"
:show-reset-button="false"
@on-copy="onCopyToken"
/>
</div> </div>
<div class="flex items-center justify-end w-full gap-2 px-0 py-2"> <div class="flex items-center justify-end w-full gap-2 px-0 py-2">
@@ -1,21 +1,17 @@
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue'; import { ref, computed } from 'vue';
import ForwardToOption from './emailChannels/ForwardToOption.vue'; import ForwardToOption from './emailChannels/ForwardToOption.vue';
import Microsoft from './emailChannels/Microsoft.vue'; import Microsoft from './emailChannels/Microsoft.vue';
import Google from './emailChannels/Google.vue'; import Google from './emailChannels/Google.vue';
import ChannelSelector from 'dashboard/components/ChannelSelector.vue'; import ChannelSelector from 'dashboard/components/ChannelSelector.vue';
import PageHeader from '../../SettingsSubPageHeader.vue'; import PageHeader from '../../SettingsSubPageHeader.vue';
import { useAlert } from 'dashboard/composables';
import { useStoreGetters } from 'dashboard/composables/store'; import { useStoreGetters } from 'dashboard/composables/store';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
const provider = ref(''); const provider = ref('');
const getters = useStoreGetters(); const getters = useStoreGetters();
const route = useRoute();
const { t } = useI18n(); const { t } = useI18n();
const globalConfig = getters['globalConfig/get']; const globalConfig = getters['globalConfig/get'];
@@ -49,15 +45,6 @@ const emailProviderList = computed(() => {
}); });
}); });
const checkForOAuthError = () => {
const { error } = route.query;
if (error) {
useAlert(decodeURIComponent(error));
}
};
onMounted(checkForOAuthError);
function onClick(emailProvider) { function onClick(emailProvider) {
if (emailProvider.isEnabled) { if (emailProvider.isEnabled) {
provider.value = emailProvider.key; provider.value = emailProvider.key;
@@ -12,6 +12,7 @@ defineOptions({
provider="google" provider="google"
:title="$t('INBOX_MGMT.ADD.GOOGLE.TITLE')" :title="$t('INBOX_MGMT.ADD.GOOGLE.TITLE')"
:description="$t('INBOX_MGMT.ADD.GOOGLE.DESCRIPTION')" :description="$t('INBOX_MGMT.ADD.GOOGLE.DESCRIPTION')"
:input-placeholder="$t('INBOX_MGMT.ADD.GOOGLE.EMAIL_PLACEHOLDER')"
:submit-button-text="$t('INBOX_MGMT.ADD.GOOGLE.SIGN_IN')" :submit-button-text="$t('INBOX_MGMT.ADD.GOOGLE.SIGN_IN')"
:error-message="$t('INBOX_MGMT.ADD.GOOGLE.ERROR_MESSAGE')" :error-message="$t('INBOX_MGMT.ADD.GOOGLE.ERROR_MESSAGE')"
/> />
@@ -12,6 +12,7 @@ defineOptions({
provider="microsoft" provider="microsoft"
:title="$t('INBOX_MGMT.ADD.MICROSOFT.TITLE')" :title="$t('INBOX_MGMT.ADD.MICROSOFT.TITLE')"
:description="$t('INBOX_MGMT.ADD.MICROSOFT.DESCRIPTION')" :description="$t('INBOX_MGMT.ADD.MICROSOFT.DESCRIPTION')"
:input-placeholder="$t('INBOX_MGMT.ADD.MICROSOFT.EMAIL_PLACEHOLDER')"
:submit-button-text="$t('INBOX_MGMT.ADD.MICROSOFT.SIGN_IN')" :submit-button-text="$t('INBOX_MGMT.ADD.MICROSOFT.SIGN_IN')"
:error-message="$t('INBOX_MGMT.ADD.MICROSOFT.ERROR_MESSAGE')" :error-message="$t('INBOX_MGMT.ADD.MICROSOFT.ERROR_MESSAGE')"
/> />
@@ -30,9 +30,14 @@ const props = defineProps({
type: String, type: String,
required: true, required: true,
}, },
inputPlaceholder: {
type: String,
required: true,
},
}); });
const isRequestingAuthorization = ref(false); const isRequestingAuthorization = ref(false);
const email = ref('');
const client = computed(() => { const client = computed(() => {
if (props.provider === 'microsoft') { if (props.provider === 'microsoft') {
@@ -45,7 +50,9 @@ const client = computed(() => {
async function requestAuthorization() { async function requestAuthorization() {
try { try {
isRequestingAuthorization.value = true; isRequestingAuthorization.value = true;
const response = await client.value.generateAuthorization(); const response = await client.value.generateAuthorization({
email: email.value,
});
const { const {
data: { url }, data: { url },
} = response; } = response;
@@ -68,6 +75,11 @@ async function requestAuthorization() {
:header-content="description" :header-content="description"
/> />
<form class="mt-6" @submit.prevent="requestAuthorization"> <form class="mt-6" @submit.prevent="requestAuthorization">
<woot-input
v-model="email"
type="email"
:placeholder="inputPlaceholder"
/>
<NextButton <NextButton
:is-loading="isRequestingAuthorization" :is-loading="isRequestingAuthorization"
type="submit" type="submit"
@@ -1,19 +1,35 @@
<script setup> <script setup>
import { ref } from 'vue'; import { ref, computed } from 'vue';
import InboxReconnectionRequired from '../../components/InboxReconnectionRequired.vue'; import InboxReconnectionRequired from '../../components/InboxReconnectionRequired.vue';
import googleClient from 'dashboard/api/channel/googleClient'; import googleClient from 'dashboard/api/channel/googleClient';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
const props = defineProps({
inbox: {
type: Object,
default: () => ({}),
},
});
const { t } = useI18n(); const { t } = useI18n();
const isRequestingAuthorization = ref(false); const isRequestingAuthorization = ref(false);
const inboxEmail = computed(() => {
if (props.inbox.imap_login && props.inbox.imap_enabled) {
return props.inbox.imap_login;
}
return props.inbox.email;
});
async function requestAuthorization() { async function requestAuthorization() {
try { try {
isRequestingAuthorization.value = true; isRequestingAuthorization.value = true;
const response = await googleClient.generateAuthorization(); const response = await googleClient.generateAuthorization({
email: inboxEmail.value,
});
const { const {
data: { url }, data: { url },
@@ -6,6 +6,13 @@ import microsoftClient from 'dashboard/api/channel/microsoftClient';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
const props = defineProps({
inbox: {
type: Object,
default: () => ({}),
},
});
const { t } = useI18n(); const { t } = useI18n();
const isRequestingAuthorization = ref(false); const isRequestingAuthorization = ref(false);
@@ -13,7 +20,9 @@ const isRequestingAuthorization = ref(false);
async function requestAuthorization() { async function requestAuthorization() {
try { try {
isRequestingAuthorization.value = true; isRequestingAuthorization.value = true;
const response = await microsoftClient.generateAuthorization(); const response = await microsoftClient.generateAuthorization({
email: props.inbox.email,
});
const { const {
data: { url }, data: { url },
@@ -1,17 +1,14 @@
<script setup> <script setup>
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import NextButton from 'dashboard/components-next/button/Button.vue'; import FormButton from 'v3/components/Form/Button.vue';
import ConfirmButton from 'dashboard/components-next/button/ConfirmButton.vue';
const props = defineProps({ const props = defineProps({
value: { type: String, default: '' }, value: {
showResetButton: { type: Boolean, default: true }, type: String,
default: '',
},
}); });
const emit = defineEmits(['onCopy']);
const emit = defineEmits(['onCopy', 'onReset']);
const inputType = ref('password'); const inputType = ref('password');
const toggleMasked = () => { const toggleMasked = () => {
inputType.value = inputType.value === 'password' ? 'text' : 'password'; inputType.value = inputType.value === 'password' ? 'text' : 'password';
}; };
@@ -23,10 +20,6 @@ const maskIcon = computed(() => {
const onClick = () => { const onClick = () => {
emit('onCopy', props.value); emit('onCopy', props.value);
}; };
const onReset = () => {
emit('onReset');
};
</script> </script>
<template> <template>
@@ -45,7 +38,7 @@ const onReset = () => {
> >
<template #masked> <template #masked>
<button <button
class="absolute top-0 bottom-0 ltr:right-0.5 rtl:left-0.5" class="absolute top-1.5 ltr:right-0.5 rtl:left-0.5"
type="button" type="button"
@click="toggleMasked" @click="toggleMasked"
> >
@@ -53,28 +46,15 @@ const onReset = () => {
</button> </button>
</template> </template>
</woot-input> </woot-input>
<div class="flex flex-row gap-2"> <FormButton
<NextButton type="button"
:label="$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.COPY')" size="large"
slate icon="text-copy"
outline variant="outline"
type="button" color-scheme="secondary"
icon="i-lucide-copy" @click="onClick"
class="rounded-xl" >
@click="onClick" {{ $t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.COPY') }}
/> </FormButton>
<ConfirmButton
v-if="showResetButton"
:label="$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.RESET')"
:confirm-label="$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.CONFIRM_RESET')"
:confirm-hint="$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.CONFIRM_HINT')"
color="slate"
confirm-color="ruby"
variant="outline"
icon="i-lucide-key-round"
class="rounded-xl"
@click="onReset"
/>
</div>
</div> </div>
</template> </template>
@@ -181,14 +181,6 @@ export default {
await copyTextToClipboard(value); await copyTextToClipboard(value);
useAlert(this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL')); useAlert(this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
}, },
async resetAccessToken() {
const success = await this.$store.dispatch('resetAccessToken');
if (success) {
useAlert(this.$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.RESET_SUCCESS'));
} else {
useAlert(this.$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.RESET_ERROR'));
}
},
}, },
}; };
</script> </script>
@@ -245,12 +237,7 @@ export default {
<button <button
v-for="hotKey in hotKeys" v-for="hotKey in hotKeys"
:key="hotKey.key" :key="hotKey.key"
class="px-0 reset-base w-full sm:flex-1 rounded-xl outline-1 outline" class="px-0 reset-base w-full sm:flex-1"
:class="
isEditorHotKeyEnabled(hotKey.key)
? 'outline-n-brand/30'
: 'outline-n-weak'
"
> >
<HotKeyCard <HotKeyCard
:key="hotKey.title" :key="hotKey.title"
@@ -294,11 +281,7 @@ export default {
) )
" "
> >
<AccessToken <AccessToken :value="currentUser.access_token" @on-copy="onCopyToken" />
:value="currentUser.access_token"
@on-copy="onCopyToken"
@on-reset="resetAccessToken"
/>
</FormSection> </FormSection>
</div> </div>
</template> </template>
@@ -1,6 +1,4 @@
<script setup> <script setup>
import { computed } from 'vue';
const { row } = defineProps({ const { row } = defineProps({
row: { row: {
type: Object, type: Object,
@@ -8,10 +6,10 @@ const { row } = defineProps({
}, },
}); });
const routerParams = computed(() => ({ const routerParams = {
name: 'inbox_conversation', name: 'inbox_conversation',
params: { conversation_id: row.original.conversationId }, params: { conversation_id: row.original.conversationId },
})); };
</script> </script>
<template> <template>
@@ -47,17 +47,14 @@ const tableData = computed(() => {
})); }));
}); });
const defaultSpanRender = cellProps => { const defaulSpanRender = cellProps =>
const value = cellProps.getValue() || '---'; h(
return h(
'span', 'span',
{ {
class: 'line-clamp-5 break-words max-w-full text-n-slate-12', class: cellProps.getValue() ? '' : 'text-slate-300 dark:text-slate-700',
title: value,
}, },
value cellProps.getValue() ? cellProps.getValue() : '---'
); );
};
const columnHelper = createColumnHelper(); const columnHelper = createColumnHelper();
@@ -68,10 +65,7 @@ const columns = [
cell: cellProps => { cell: cellProps => {
const { contact } = cellProps.row.original; const { contact } = cellProps.row.original;
if (contact) { if (contact) {
return h(UserAvatarWithName, { return h(UserAvatarWithName, { user: contact });
user: contact,
class: 'max-w-[200px] overflow-hidden',
});
} }
return '--'; return '--';
}, },
@@ -82,10 +76,7 @@ const columns = [
cell: cellProps => { cell: cellProps => {
const { assignedAgent } = cellProps.row.original; const { assignedAgent } = cellProps.row.original;
if (assignedAgent) { if (assignedAgent) {
return h(UserAvatarWithName, { return h(UserAvatarWithName, { user: assignedAgent });
user: assignedAgent,
class: 'max-w-[200px] overflow-hidden',
});
} }
return '--'; return '--';
}, },
@@ -114,7 +105,7 @@ const columns = [
columnHelper.accessor('feedbackText', { columnHelper.accessor('feedbackText', {
header: t('CSAT_REPORTS.TABLE.HEADER.FEEDBACK_TEXT'), header: t('CSAT_REPORTS.TABLE.HEADER.FEEDBACK_TEXT'),
width: 400, width: 400,
cell: defaultSpanRender, cell: defaulSpanRender,
}), }),
columnHelper.accessor('conversationId', { columnHelper.accessor('conversationId', {
header: '', header: '',
@@ -172,17 +172,6 @@ export const actions = {
commit(types.SET_AGENT_BOT_UI_FLAG, { isDisconnecting: false }); commit(types.SET_AGENT_BOT_UI_FLAG, { isDisconnecting: false });
} }
}, },
resetAccessToken: async ({ commit }, botId) => {
try {
const response = await AgentBotsAPI.resetAccessToken(botId);
commit(types.EDIT_AGENT_BOT, response.data);
return response.data;
} catch (error) {
throwErrorMessage(error);
return null;
}
},
}; };
export const mutations = { export const mutations = {
@@ -213,16 +213,6 @@ export const actions = {
} }
}, },
resetAccessToken: async ({ commit }) => {
try {
const response = await authAPI.resetAccessToken();
commit(types.SET_CURRENT_USER, response.data);
return true;
} catch (error) {
return false;
}
},
resendConfirmation: async () => { resendConfirmation: async () => {
try { try {
await authAPI.resendConfirmation(); await authAPI.resendConfirmation();
@@ -18,34 +18,13 @@ const getters = {
getAllConversations: ({ allConversations, chatSortFilter: sortKey }) => { getAllConversations: ({ allConversations, chatSortFilter: sortKey }) => {
return allConversations.sort((a, b) => sortComparator(a, b, sortKey)); return allConversations.sort((a, b) => sortComparator(a, b, sortKey));
}, },
getFilteredConversations: ( getFilteredConversations: ({
{ allConversations, chatSortFilter, appliedFilters }, allConversations,
_, chatSortFilter,
__, appliedFilters,
rootGetters }) => {
) => {
const currentUser = rootGetters.getCurrentUser;
const currentUserId = rootGetters.getCurrentUser.id;
const currentAccountId = rootGetters.getCurrentAccountId;
const permissions = getUserPermissions(currentUser, currentAccountId);
const userRole = getUserRole(currentUser, currentAccountId);
return allConversations return allConversations
.filter(conversation => { .filter(conversation => matchesFilters(conversation, appliedFilters))
const matchesFilterResult = matchesFilters(
conversation,
appliedFilters
);
const allowedForRole = applyRoleFilter(
conversation,
userRole,
permissions,
currentUserId
);
return matchesFilterResult && allowedForRole;
})
.sort((a, b) => sortComparator(a, b, chatSortFilter)); .sort((a, b) => sortComparator(a, b, chatSortFilter));
}, },
getSelectedChat: ({ selectedChatId, allConversations }) => { getSelectedChat: ({ selectedChatId, allConversations }) => {
@@ -170,21 +170,4 @@ describe('#actions', () => {
]); ]);
}); });
}); });
describe('#resetAccessToken', () => {
it('sends correct actions if API is success', async () => {
const mockResponse = {
data: { ...agentBotRecords[0], access_token: 'new_token_123' },
};
axios.post.mockResolvedValue(mockResponse);
const result = await actions.resetAccessToken(
{ commit },
agentBotRecords[0].id
);
expect(commit.mock.calls).toEqual([
[types.EDIT_AGENT_BOT, mockResponse.data],
]);
expect(result).toBe(mockResponse.data);
});
});
}); });
@@ -228,20 +228,4 @@ describe('#actions', () => {
); );
}); });
}); });
describe('#resetAccessToken', () => {
it('sends correct actions if API is success', async () => {
const mockResponse = {
data: { id: 1, name: 'John', access_token: 'new_token_123' },
headers: { expiry: 581842904 },
};
axios.post.mockResolvedValue(mockResponse);
const result = await actions.resetAccessToken({ commit });
expect(commit.mock.calls).toEqual([
[types.SET_CURRENT_USER, mockResponse.data],
]);
expect(result).toBe(true);
});
});
}); });
@@ -325,308 +325,4 @@ describe('#getters', () => {
}); });
}); });
}); });
describe('#getFilteredConversations', () => {
const mockConversations = [
{
id: 1,
status: 'open',
meta: { assignee: { id: 1 } },
last_activity_at: 1000,
},
{
id: 2,
status: 'open',
meta: {},
last_activity_at: 2000,
},
{
id: 3,
status: 'resolved',
meta: { assignee: { id: 2 } },
last_activity_at: 3000,
},
];
const mockRootGetters = {
getCurrentUser: {
id: 1,
accounts: [{ id: 1, role: 'agent', permissions: [] }],
},
getCurrentAccountId: 1,
};
it('filters conversations based on role permissions for administrator', () => {
const state = {
allConversations: mockConversations,
chatSortFilter: 'last_activity_at_desc',
appliedFilters: [],
};
const rootGetters = {
...mockRootGetters,
getCurrentUser: {
...mockRootGetters.getCurrentUser,
accounts: [{ id: 1, role: 'administrator', permissions: [] }],
},
};
const result = getters.getFilteredConversations(
state,
{},
{},
rootGetters
);
expect(result).toEqual([
mockConversations[2],
mockConversations[1],
mockConversations[0],
]);
});
it('filters conversations based on role permissions for agent', () => {
const state = {
allConversations: mockConversations,
chatSortFilter: 'last_activity_at_desc',
appliedFilters: [],
};
const rootGetters = {
...mockRootGetters,
getCurrentUser: {
...mockRootGetters.getCurrentUser,
accounts: [{ id: 1, role: 'agent', permissions: [] }],
},
};
const result = getters.getFilteredConversations(
state,
{},
{},
rootGetters
);
expect(result).toEqual([
mockConversations[2],
mockConversations[1],
mockConversations[0],
]);
});
it('filters conversations for custom role with conversation_manage permission', () => {
const state = {
allConversations: mockConversations,
chatSortFilter: 'last_activity_at_desc',
appliedFilters: [],
};
const rootGetters = {
...mockRootGetters,
getCurrentUser: {
...mockRootGetters.getCurrentUser,
accounts: [
{
id: 1,
custom_role_id: 5,
permissions: ['conversation_manage'],
},
],
},
};
const result = getters.getFilteredConversations(
state,
{},
{},
rootGetters
);
expect(result).toEqual([
mockConversations[2],
mockConversations[1],
mockConversations[0],
]);
});
it('filters conversations for custom role with conversation_unassigned_manage permission', () => {
const state = {
allConversations: mockConversations,
chatSortFilter: 'last_activity_at_desc',
appliedFilters: [],
};
const rootGetters = {
...mockRootGetters,
getCurrentUser: {
...mockRootGetters.getCurrentUser,
accounts: [
{
id: 1,
custom_role_id: 5,
permissions: ['conversation_unassigned_manage'],
},
],
},
};
const result = getters.getFilteredConversations(
state,
{},
{},
rootGetters
);
// Should include conversation assigned to user (id: 1) and unassigned conversation
expect(result).toEqual([mockConversations[1], mockConversations[0]]);
});
it('filters conversations for custom role with conversation_participating_manage permission', () => {
const state = {
allConversations: mockConversations,
chatSortFilter: 'last_activity_at_desc',
appliedFilters: [],
};
const rootGetters = {
...mockRootGetters,
getCurrentUser: {
...mockRootGetters.getCurrentUser,
accounts: [
{
id: 1,
custom_role_id: 5,
permissions: ['conversation_participating_manage'],
},
],
},
};
const result = getters.getFilteredConversations(
state,
{},
{},
rootGetters
);
// Should only include conversation assigned to user (id: 1)
expect(result).toEqual([mockConversations[0]]);
});
it('filters conversations for custom role with no permissions', () => {
const state = {
allConversations: mockConversations,
chatSortFilter: 'last_activity_at_desc',
appliedFilters: [],
};
const rootGetters = {
...mockRootGetters,
getCurrentUser: {
...mockRootGetters.getCurrentUser,
accounts: [
{
id: 1,
custom_role_id: 5,
permissions: [],
},
],
},
};
const result = getters.getFilteredConversations(
state,
{},
{},
rootGetters
);
// Should return empty array as user has no permissions
expect(result).toEqual([]);
});
it('applies filters and role permissions together', () => {
const state = {
allConversations: mockConversations,
chatSortFilter: 'last_activity_at_desc',
appliedFilters: [
{
attribute_key: 'status',
filter_operator: 'equal_to',
values: ['open'],
query_operator: 'and',
},
],
};
const rootGetters = {
...mockRootGetters,
getCurrentUser: {
...mockRootGetters.getCurrentUser,
accounts: [
{
id: 1,
custom_role_id: 5,
permissions: ['conversation_participating_manage'],
},
],
},
};
const result = getters.getFilteredConversations(
state,
{},
{},
rootGetters
);
// Should only include open conversation assigned to user (id: 1)
expect(result).toEqual([mockConversations[0]]);
});
it('returns empty array when no conversations match filters', () => {
const state = {
allConversations: mockConversations,
chatSortFilter: 'last_activity_at_desc',
appliedFilters: [
{
attribute_key: 'status',
filter_operator: 'equal_to',
values: ['pending'],
query_operator: 'and',
},
],
};
const result = getters.getFilteredConversations(
state,
{},
{},
mockRootGetters
);
expect(result).toEqual([]);
});
it('sorts filtered conversations according to chatSortFilter', () => {
const state = {
allConversations: mockConversations,
chatSortFilter: 'last_activity_at_asc',
appliedFilters: [],
};
const result = getters.getFilteredConversations(
state,
{},
{},
mockRootGetters
);
expect(result).toEqual([
mockConversations[0],
mockConversations[1],
mockConversations[2],
]);
});
});
}); });
+3 -3
View File
@@ -64,11 +64,11 @@ export default {
:selected="modelValue" :selected="modelValue"
:name="name" :name="name"
:class="{ :class="{
'text-n-slate-9': !modelValue, 'text-ash-400': !modelValue,
'text-n-slate-12': modelValue, 'text-ash-900': modelValue,
'pl-9': icon, 'pl-9': icon,
}" }"
class="block w-full px-3 py-2 pr-6 mb-0 border-0 shadow-sm appearance-none rounded-xl select-caret leading-6" class="block w-full px-3 py-2 pr-6 mb-0 border-0 shadow-sm outline-none appearance-none rounded-xl select-caret ring-ash-200 ring-1 ring-inset placeholder:text-ash-900 focus:ring-2 focus:ring-inset focus:ring-primary-500 text-sm leading-6"
@input="onInput" @input="onInput"
> >
<option value="" disabled selected class="hidden"> <option value="" disabled selected class="hidden">
@@ -5,6 +5,7 @@ module ActivityMessageHandler
include LabelActivityMessageHandler include LabelActivityMessageHandler
include SlaActivityMessageHandler include SlaActivityMessageHandler
include TeamActivityMessageHandler include TeamActivityMessageHandler
include CsatActivityMessageHandler
private private
@@ -0,0 +1,8 @@
module CsatActivityMessageHandler
extend ActiveSupport::Concern
def create_csat_not_sent_activity_message
content = I18n.t('conversations.activity.csat.not_sent_due_to_messaging_window')
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
end
end
-4
View File
@@ -22,8 +22,4 @@ class AgentBotPolicy < ApplicationPolicy
def avatar? def avatar?
@account_user.administrator? @account_user.administrator?
end end
def reset_access_token?
@account_user.administrator?
end
end end
@@ -11,5 +11,3 @@ class CsatSurveyResponsePolicy < ApplicationPolicy
@account_user.administrator? @account_user.administrator?
end end
end end
CsatSurveyResponsePolicy.prepend_mod_with('CsatSurveyResponsePolicy')
@@ -17,7 +17,7 @@ class MessageTemplates::HookExecutionService
::MessageTemplates::Template::OutOfOffice.new(conversation: conversation).perform if should_send_out_of_office_message? ::MessageTemplates::Template::OutOfOffice.new(conversation: conversation).perform if should_send_out_of_office_message?
::MessageTemplates::Template::Greeting.new(conversation: conversation).perform if should_send_greeting? ::MessageTemplates::Template::Greeting.new(conversation: conversation).perform if should_send_greeting?
::MessageTemplates::Template::EmailCollect.new(conversation: conversation).perform if inbox.enable_email_collect && should_send_email_collect? ::MessageTemplates::Template::EmailCollect.new(conversation: conversation).perform if inbox.enable_email_collect && should_send_email_collect?
::MessageTemplates::Template::CsatSurvey.new(conversation: conversation).perform if should_send_csat_survey? handle_csat_survey
end end
def should_send_out_of_office_message? def should_send_out_of_office_message?
@@ -65,13 +65,26 @@ class MessageTemplates::HookExecutionService
true true
end end
def should_send_csat_survey? def handle_csat_survey
return unless csat_enabled_conversation? return unless csat_enabled_conversation?
# only send CSAT once in a conversation # only send CSAT once in a conversation
return if conversation.messages.where(content_type: :input_csat).present? return if csat_already_sent?
true # Only send CSAT if agent can still reply by checking the messaging window restriction
# https://www.chatwoot.com/docs/self-hosted/supported-features#outgoing-message-restriction
if within_messaging_window?
::MessageTemplates::Template::CsatSurvey.new(conversation: conversation).perform
else
conversation.create_csat_not_sent_activity_message
end
end
def csat_already_sent?
conversation.messages.where(content_type: :input_csat).present?
end
def within_messaging_window?
conversation.can_reply?
end end
end end
MessageTemplates::HookExecutionService.prepend_mod_with('MessageTemplates::HookExecutionService') MessageTemplates::HookExecutionService.prepend_mod_with('MessageTemplates::HookExecutionService')
@@ -1 +0,0 @@
json.partial! 'api/v1/models/agent_bot', formats: [:json], resource: AgentBotPresenter.new(@agent_bot)
@@ -1 +0,0 @@
json.partial! 'api/v1/models/user', formats: [:json], resource: @user
-24
View File
@@ -73,30 +73,6 @@ By default, it renders:
<% end %> <% end %>
</main> </main>
</div> </div>
<% if @article.present? %>
<script>
(function() {
let viewTracked = false;
const trackView = function() {
if (!viewTracked) {
viewTracked = true;
const img = new Image();
img.src = '<%= request.base_url %>/hc/<%= @portal.slug %>/articles/<%= @article.slug %>.png';
}
};
const addTrackingListeners = function() {
const events = ['mouseenter', 'touchstart', 'focus'];
events.forEach(event => {
document.body.addEventListener(event, function() {
setTimeout(trackView, 5000);
}, { once: true });
});
};
addTrackingListeners();
})();
</script>
<% end %>
</body> </body>
<style> <style>
html.dark { html.dark {
+1 -1
View File
@@ -66,7 +66,7 @@ Rails.application.configure do
# GitHub Codespaces configuration # GitHub Codespaces configuration
if ENV['CODESPACES'] if ENV['CODESPACES']
# Allow web console access from any IP # Allow web console access from any IP
config.web_console.allowed_ips = %w(0.0.0.0/0 ::/0) config.web_console.whitelisted_ips = %w(0.0.0.0/0 ::/0)
# Allow CSRF from codespace URLs # Allow CSRF from codespace URLs
config.force_ssl = false config.force_ssl = false
config.action_controller.forgery_protection_origin_check = false config.action_controller.forgery_protection_origin_check = false
+2 -3
View File
@@ -48,9 +48,6 @@ en:
email_already_exists: 'You have already signed up for an account with %{email}' email_already_exists: 'You have already signed up for an account with %{email}'
invalid_params: 'Invalid, please check the signup paramters and try again' invalid_params: 'Invalid, please check the signup paramters and try again'
failed: Signup failed failed: Signup failed
oauth:
insufficient_scopes: 'Insufficient permissions granted. Missing scopes: %{scopes}'
authorization_failed: 'Authorization failed'
data_import: data_import:
data_type: data_type:
invalid: Invalid data type invalid: Invalid data type
@@ -188,6 +185,8 @@ en:
removed: '%{user_name} removed %{labels}' removed: '%{user_name} removed %{labels}'
sla: sla:
added: '%{user_name} added SLA policy %{sla_name}' added: '%{user_name} added SLA policy %{sla_name}'
csat:
not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
removed: '%{user_name} removed SLA policy %{sla_name}' removed: '%{user_name} removed SLA policy %{sla_name}'
muted: '%{user_name} has muted the conversation' muted: '%{user_name} has muted the conversation'
unmuted: '%{user_name} has unmuted the conversation' unmuted: '%{user_name} has unmuted the conversation'
-132
View File
@@ -1,132 +0,0 @@
# Markdown Embed Configuration
#
# This file defines patterns and templates for converting URLs into embedded content
# in markdown rendering. Each embed type has:
# - regex: Pattern with named capture groups (?<name>...)
# - template: HTML template with %{capture_group_name} placeholders
#
# To add a new embed type:
# 1. Add a new top-level key
# 2. Define the regex pattern with named capture groups: (?<name>pattern)
# 3. Create an HTML template using %{name} placeholders matching the capture groups
youtube:
regex: 'https?://(?:www\.)?(?:youtube\.com/watch\?v=|youtu\.be/)(?<video_id>[^&/]+)'
template: |
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
<iframe
src="https://www.youtube-nocookie.com/embed/%{video_id}"
frameborder="0"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen></iframe>
</div>
loom:
regex: 'https?://(?:www\.)?loom\.com/share/(?<video_id>[^&/]+)'
template: |
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
<iframe
src="https://www.loom.com/embed/%{video_id}"
frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></iframe>
</div>
vimeo:
regex: 'https?://(?:www\.)?vimeo\.com/(?<video_id>\d+)'
template: |
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
<iframe
src="https://player.vimeo.com/video/%{video_id}?dnt=true"
frameborder="0"
allow="autoplay; fullscreen; picture-in-picture"
allowfullscreen
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></iframe>
</div>
mp4:
regex: '(?<link_url>https?://(?:www\.)?.+\.mp4)'
template: |
<video width="640" height="360" controls>
<source src="%{link_url}" type="video/mp4">
Your browser does not support the video tag.
</video>
arcade:
regex: 'https?://(?:www\.)?app\.arcade\.software/share/(?<video_id>[^&/]+)'
template: |
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
<iframe
src="https://app.arcade.software/embed/%{video_id}"
frameborder="0"
webkitallowfullscreen
mozallowfullscreen
allowfullscreen
allow="fullscreen"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;">
</iframe>
</div>
wistia:
regex: 'https?://(?:www\.)?[^/]+\.wistia\.com/medias/(?<video_id>[^&/]+)'
template: |
<div style="position: relative; padding-bottom: 56.25%; height: 0;">
<script src="https://fast.wistia.com/player.js" async></script>
<script src="https://fast.wistia.com/embed/%{video_id}.js" async type="module"></script>
<style>
wistia-player[media-id='%{video_id}']:not(:defined) {
background: center / contain no-repeat url('https://fast.wistia.com/embed/medias/%{video_id}/swatch');
display: block;
filter: blur(5px);
padding-top:56.25%;
}
</style>
<wistia-player
media-id="%{video_id}"
aspect="1.7777777777777777"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;">
</wistia-player>
</div>
bunny:
regex: 'https?://iframe\.mediadelivery\.net/play/(?<library_id>\d+)/(?<video_id>[^&/?]+)'
template: |
<div style="position: relative; padding-top: 56.25%;">
<iframe
src="https://iframe.mediadelivery.net/embed/%{library_id}/%{video_id}?autoplay=false&loop=false&muted=false&preload=true&responsive=true"
title="Bunny video player"
loading="lazy"
style="border: 0; position: absolute; top: 0; height: 100%; width: 100%;"
allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture;"
allowfullscreen>
</iframe>
</div>
codepen:
regex: 'https?://(?:www\.)?codepen\.io/(?<user>[^/]+)/pen/(?<pen_id>[^/?]+)'
template: |
<div style="height: 400px; box-sizing: border-box; display: flex; align-items: center; justify-content: center;">
<iframe
height="400"
style="width: 100%;"
scrolling="no"
title="CodePen Embed"
src="https://codepen.io/%{user}/embed/%{pen_id}?default-tab=result"
frameborder="no"
loading="lazy"
allowtransparency="true"
allowfullscreen="true">
</iframe>
</div>
github_gist:
regex: 'https?://gist\.github\.com/(?<username>[^/]+)/(?<gist_id>[a-f0-9]+)'
template: |
<script src="https://gist.github.com/%{username}/%{gist_id}.js"></script>
<noscript>
<div style="border: 1px solid #d1d9e0; border-radius: 6px; padding: 16px; margin: 16px 0; background: #f6f8fa;">
<a href="https://gist.github.com/%{username}/%{gist_id}" target="_blank" style="color: #0969da; text-decoration: none;">
View this gist on GitHub
</a>
</div>
</noscript>
-3
View File
@@ -67,7 +67,6 @@ Rails.application.routes.draw do
end end
resources :agent_bots, only: [:index, :create, :show, :update, :destroy] do resources :agent_bots, only: [:index, :create, :show, :update, :destroy] do
delete :avatar, on: :member delete :avatar, on: :member
post :reset_access_token, on: :member
end end
resources :contact_inboxes, only: [] do resources :contact_inboxes, only: [] do
collection do collection do
@@ -297,7 +296,6 @@ Rails.application.routes.draw do
post :auto_offline post :auto_offline
put :set_active_account put :set_active_account
post :resend_confirmation post :resend_confirmation
post :reset_access_token
end end
end end
@@ -449,7 +447,6 @@ Rails.application.routes.draw do
get 'hc/:slug/:locale/categories', to: 'public/api/v1/portals/categories#index' get 'hc/:slug/:locale/categories', to: 'public/api/v1/portals/categories#index'
get 'hc/:slug/:locale/categories/:category_slug', to: 'public/api/v1/portals/categories#show' get 'hc/:slug/:locale/categories/:category_slug', to: 'public/api/v1/portals/categories#show'
get 'hc/:slug/:locale/categories/:category_slug/articles', to: 'public/api/v1/portals/articles#index' get 'hc/:slug/:locale/categories/:category_slug/articles', to: 'public/api/v1/portals/articles#index'
get 'hc/:slug/articles/:article_slug.png', to: 'public/api/v1/portals/articles#tracking_pixel'
get 'hc/:slug/articles/:article_slug', to: 'public/api/v1/portals/articles#show' get 'hc/:slug/articles/:article_slug', to: 'public/api/v1/portals/articles#show'
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
@@ -1,13 +0,0 @@
module Enterprise::CsatSurveyResponsePolicy
def index?
@account_user.custom_role&.permissions&.include?('report_manage') || super
end
def metrics?
@account_user.custom_role&.permissions&.include?('report_manage') || super
end
def download?
@account_user.custom_role&.permissions&.include?('report_manage') || super
end
end
+60 -35
View File
@@ -1,13 +1,13 @@
class CustomMarkdownRenderer < CommonMarker::HtmlRenderer class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
CONFIG_PATH = Rails.root.join('config/markdown_embeds.yml') # TODO: let move this regex from here to a config file where we can update this list much more easily
# the config file will also have the matching embed template as well.
def self.config YOUTUBE_REGEX = %r{https?://(?:www\.)?(?:youtube\.com/watch\?v=|youtu\.be/)([^&/]+)}
@config ||= YAML.load_file(CONFIG_PATH) LOOM_REGEX = %r{https?://(?:www\.)?loom\.com/share/([^&/]+)}
end VIMEO_REGEX = %r{https?://(?:www\.)?vimeo\.com/(\d+)}
MP4_REGEX = %r{https?://(?:www\.)?.+\.(mp4)}
def self.embed_regexes ARCADE_REGEX = %r{https?://(?:www\.)?app\.arcade\.software/share/([^&/]+)}
@embed_regexes ||= config.transform_values { |embed_config| Regexp.new(embed_config['regex']) } WISTIA_REGEX = %r{https?://(?:www\.)?([^/]+)\.wistia\.com/medias/([^&/]+)}
end BUNNY_REGEX = %r{https?://iframe\.mediadelivery\.net/play/(\d+)/([^&/?]+)}
def text(node) def text(node)
content = node.string_content content = node.string_content
@@ -23,7 +23,7 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
def link(node) def link(node)
return if surrounded_by_empty_lines?(node) && render_embedded_content(node) return if surrounded_by_empty_lines?(node) && render_embedded_content(node)
# If it's not a supported embed link, render normally # If it's not YouTube or Vimeo link, render normally
super super
end end
@@ -47,35 +47,25 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
def render_embedded_content(node) def render_embedded_content(node)
link_url = node.url link_url = node.url
embed_html = find_matching_embed(link_url) embedding_methods = {
YOUTUBE_REGEX => :make_youtube_embed,
VIMEO_REGEX => :make_vimeo_embed,
MP4_REGEX => :make_video_embed,
LOOM_REGEX => :make_loom_embed,
ARCADE_REGEX => :make_arcade_embed,
WISTIA_REGEX => :make_wistia_embed,
BUNNY_REGEX => :make_bunny_embed
}
return false unless embed_html embedding_methods.each do |regex, method|
out(embed_html)
true
end
def find_matching_embed(link_url)
self.class.embed_regexes.each do |embed_key, regex|
match = link_url.match(regex) match = link_url.match(regex)
next unless match if match
out(send(method, match))
return render_embed_from_match(embed_key, match) return true
end
end end
nil false
end
def render_embed_from_match(embed_key, match_data)
embed_config = self.class.config[embed_key]
return nil unless embed_config
template = embed_config['template']
# Use Ruby's built-in named captures with gsub to handle CSS % values
match_data.named_captures.each do |var_name, value|
template = template.gsub("%{#{var_name}}", value)
end
template
end end
def parse_sup(content) def parse_sup(content)
@@ -87,4 +77,39 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
end end
end end
end end
def make_youtube_embed(youtube_match)
video_id = youtube_match[1]
EmbedRenderer.youtube(video_id)
end
def make_loom_embed(loom_match)
video_id = loom_match[1]
EmbedRenderer.loom(video_id)
end
def make_vimeo_embed(vimeo_match)
video_id = vimeo_match[1]
EmbedRenderer.vimeo(video_id)
end
def make_video_embed(link_url)
EmbedRenderer.video(link_url)
end
def make_wistia_embed(wistia_match)
video_id = wistia_match[2]
EmbedRenderer.wistia(video_id)
end
def make_arcade_embed(arcade_match)
video_id = arcade_match[1]
EmbedRenderer.arcade(video_id)
end
def make_bunny_embed(bunny_match)
library_id = bunny_match[1]
video_id = bunny_match[2]
EmbedRenderer.bunny(library_id, video_id)
end
end end
+102
View File
@@ -0,0 +1,102 @@
module EmbedRenderer
def self.youtube(video_id)
%(
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
<iframe
src="https://www.youtube-nocookie.com/embed/#{video_id}"
frameborder="0"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen></iframe>
</div>
)
end
def self.loom(video_id)
%(
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
<iframe
src="https://www.loom.com/embed/#{video_id}"
frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></iframe>
</div>
)
end
def self.vimeo(video_id)
%(
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
<iframe
src="https://player.vimeo.com/video/#{video_id}?dnt=true"
frameborder="0"
allow="autoplay; fullscreen; picture-in-picture"
allowfullscreen
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></iframe>
</div>
)
end
def self.video(link_url)
%(
<video width="640" height="360" controls>
<source src="#{link_url}" type="video/mp4">
Your browser does not support the video tag.
</video>
)
end
# Generates an HTML embed for a Wistia video.
# @param wistia_match [MatchData] A match object from the WISTIA_REGEX regex, where wistia_match[2] contains the video ID.
def self.wistia(video_id)
%(
<div style="position: relative; padding-bottom: 56.25%; height: 0;">
<script src="https://fast.wistia.com/player.js" async></script>
<script src="https://fast.wistia.com/embed/#{video_id}.js" async type="module"></script>
<style>
wistia-player[media-id='#{video_id}']:not(:defined) {
background: center / contain no-repeat url('https://fast.wistia.com/embed/medias/#{video_id}/swatch');
display: block;
filter: blur(5px);
padding-top:56.25%;
}
</style>
<wistia-player
media-id="#{video_id}"
aspect="1.7777777777777777"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;">
</wistia-player>
</div>
)
end
def self.arcade(video_id)
%(
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
<iframe
src="https://app.arcade.software/embed/#{video_id}"
frameborder="0"
webkitallowfullscreen
mozallowfullscreen
allowfullscreen
allow="fullscreen"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;">
</iframe>
</div>
)
end
def self.bunny(library_id, video_id)
%(
<div style="position: relative; padding-top: 56.25%;">
<iframe
src="https://iframe.mediadelivery.net/embed/#{library_id}/#{video_id}?autoplay=false&loop=false&muted=false&preload=true&responsive=true"
title="Bunny video player"
loading="lazy"
style="border: 0; position: absolute; top: 0; height: 100%; width: 100%;"
allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture;"
allowfullscreen>
</iframe>
</div>
)
end
end
+8 -45
View File
@@ -19,7 +19,6 @@ class Seeders::AccountSeeder
def perform! def perform!
set_up_account set_up_account
seed_teams seed_teams
seed_custom_roles
set_up_users set_up_users
seed_labels seed_labels
seed_canned_responses seed_canned_responses
@@ -33,7 +32,6 @@ class Seeders::AccountSeeder
@account.labels.destroy_all @account.labels.destroy_all
@account.inboxes.destroy_all @account.inboxes.destroy_all
@account.contacts.destroy_all @account.contacts.destroy_all
@account.custom_roles.destroy_all if @account.respond_to?(:custom_roles)
end end
def seed_teams def seed_teams
@@ -42,18 +40,6 @@ class Seeders::AccountSeeder
end end
end end
def seed_custom_roles
return unless @account_data['custom_roles'].present? && @account.respond_to?(:custom_roles)
@account_data['custom_roles'].each do |role_data|
@account.custom_roles.create!(
name: role_data['name'],
description: role_data['description'],
permissions: role_data['permissions']
)
end
end
def seed_labels def seed_labels
@account_data['labels'].each do |label| @account_data['labels'].each do |label|
@account.labels.create!(label) @account.labels.create!(label)
@@ -62,34 +48,17 @@ class Seeders::AccountSeeder
def set_up_users def set_up_users
@account_data['users'].each do |user| @account_data['users'].each do |user|
user_record = create_user_record(user) user_record = User.create_with(name: user['name'], password: 'Password1!.').find_or_create_by!(email: (user['email']).to_s)
create_account_user(user_record, user) user_record.skip_confirmation!
add_user_to_teams(user: user_record, teams: user['team']) if user['team'].present? user_record.save!
Avatar::AvatarFromUrlJob.perform_later(user_record, "https://xsgames.co/randomusers/avatar.php?g=#{user['gender']}")
AccountUser.create_with(role: (user['role'] || 'agent')).find_or_create_by!(account_id: @account.id, user_id: user_record.id)
next if user['team'].blank?
add_user_to_teams(user: user_record, teams: user['team'])
end end
end end
private
def create_user_record(user)
user_record = User.create_with(name: user['name'], password: 'Password1!.').find_or_create_by!(email: user['email'].to_s)
user_record.skip_confirmation!
user_record.save!
Avatar::AvatarFromUrlJob.perform_later(user_record, "https://xsgames.co/randomusers/avatar.php?g=#{user['gender']}")
user_record
end
def create_account_user(user_record, user)
account_user_attrs = build_account_user_attrs(user)
AccountUser.create_with(account_user_attrs).find_or_create_by!(account_id: @account.id, user_id: user_record.id)
end
def build_account_user_attrs(user)
attrs = { role: (user['role'] || 'agent') }
custom_role = find_custom_role(user['custom_role']) if user['custom_role'].present?
attrs[:custom_role] = custom_role if custom_role
attrs
end
def add_user_to_teams(user:, teams:) def add_user_to_teams(user:, teams:)
teams.each do |team| teams.each do |team|
team_record = @account.teams.where('name LIKE ?', "%#{team.downcase}%").first if team.present? team_record = @account.teams.where('name LIKE ?', "%#{team.downcase}%").first if team.present?
@@ -97,12 +66,6 @@ class Seeders::AccountSeeder
end end
end end
def find_custom_role(role_name)
return nil unless @account.respond_to?(:custom_roles)
@account.custom_roles.find_by(name: role_name)
end
def seed_canned_responses(count: 50) def seed_canned_responses(count: 50)
count.times do count.times do
@account.canned_responses.create(content: Faker::Quote.fortune_cookie, short_code: Faker::Alphanumeric.alpha(number: 10)) @account.canned_responses.create(content: Faker::Quote.fortune_cookie, short_code: Faker::Alphanumeric.alpha(number: 10))
-43
View File
@@ -61,49 +61,41 @@ users:
email: 'karn@paperlayer.test' email: 'karn@paperlayer.test'
team: team:
- 'Sales' - 'Sales'
custom_role: 'Sales Representative'
- name: 'Danny Cordray' - name: 'Danny Cordray'
gender: female gender: female
email: 'danny@paperlayer.test' email: 'danny@paperlayer.test'
team: team:
- 'Sales' - 'Sales'
custom_role: 'Customer Support Lead'
- name: 'Ben Nugent' - name: 'Ben Nugent'
gender: male gender: male
email: 'ben@paperlayer.test' email: 'ben@paperlayer.test'
team: team:
- 'Sales' - 'Sales'
custom_role: 'Junior Agent'
- name: 'Todd Packer' - name: 'Todd Packer'
gender: male gender: male
email: 'todd@paperlayer.test' email: 'todd@paperlayer.test'
team: team:
- 'Sales' - 'Sales'
custom_role: 'Sales Representative'
- name: 'Cathy Simms' - name: 'Cathy Simms'
gender: female gender: female
email: 'cathy@paperlayer.test' email: 'cathy@paperlayer.test'
team: team:
- 'Administration' - 'Administration'
custom_role: 'Knowledge Manager'
- name: 'Hunter Jo' - name: 'Hunter Jo'
gender: male gender: male
email: 'hunter@paperlayer.test' email: 'hunter@paperlayer.test'
team: team:
- 'Administration' - 'Administration'
custom_role: 'Analytics Specialist'
- name: 'Rolando Silva' - name: 'Rolando Silva'
gender: male gender: male
email: 'rolando@paperlayer.test' email: 'rolando@paperlayer.test'
team: team:
- 'Administration' - 'Administration'
custom_role: 'Junior Agent'
- name: 'Stephanie Wilson' - name: 'Stephanie Wilson'
gender: female gender: female
email: 'stephanie@paperlayer.test' email: 'stephanie@paperlayer.test'
team: team:
- 'Administration' - 'Administration'
custom_role: 'Escalation Handler'
- name: 'Jordan Garfield' - name: 'Jordan Garfield'
gender: male gender: male
email: 'jorodan@paperlayer.test' email: 'jorodan@paperlayer.test'
@@ -119,7 +111,6 @@ users:
email: 'lonny@paperlayer.test' email: 'lonny@paperlayer.test'
team: team:
- 'Warehouse' - 'Warehouse'
custom_role: 'Customer Support Lead'
- name: 'Madge Madsen' - name: 'Madge Madsen'
gender: female gender: female
email: 'madge@paperlayer.test' email: 'madge@paperlayer.test'
@@ -171,7 +162,6 @@ users:
- name: 'Devon White' - name: 'Devon White'
gender: male gender: male
email: 'devon@paperlayer.test' email: 'devon@paperlayer.test'
custom_role: 'Escalation Handler'
- name: 'Kendall' - name: 'Kendall'
gender: male gender: male
email: 'kendall@paperlayer.test' email: 'kendall@paperlayer.test'
@@ -183,39 +173,6 @@ teams:
- '💼 Management' - '💼 Management'
- '👩‍💼 Administration' - '👩‍💼 Administration'
- '🚛 Warehouse' - '🚛 Warehouse'
custom_roles:
- name: 'Customer Support Lead'
description: 'Lead support agent with full conversation and contact management'
permissions:
- 'conversation_manage'
- 'contact_manage'
- 'report_manage'
- name: 'Sales Representative'
description: 'Sales team member with conversation and contact access'
permissions:
- 'conversation_unassigned_manage'
- 'conversation_participating_manage'
- 'contact_manage'
- name: 'Knowledge Manager'
description: 'Manages knowledge base and participates in conversations'
permissions:
- 'knowledge_base_manage'
- 'conversation_participating_manage'
- name: 'Junior Agent'
description: 'Entry-level agent with basic conversation access'
permissions:
- 'conversation_participating_manage'
- name: 'Analytics Specialist'
description: 'Focused on reports and data analysis'
permissions:
- 'report_manage'
- 'conversation_participating_manage'
- name: 'Escalation Handler'
description: 'Handles unassigned conversations and escalations'
permissions:
- 'conversation_unassigned_manage'
- 'conversation_participating_manage'
- 'contact_manage'
labels: labels:
- title: 'billing' - title: 'billing'
color: '#28AD21' color: '#28AD21'
Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 B

-103
View File
@@ -1,103 +0,0 @@
require 'rails_helper'
# rubocop:disable RSpec/DescribeClass
describe 'Markdown Embeds Configuration' do
# rubocop:enable RSpec/DescribeClass
let(:config) { YAML.load_file(Rails.root.join('config/markdown_embeds.yml')) }
describe 'YAML structure' do
it 'loads valid YAML' do
expect(config).to be_a(Hash)
expect(config).not_to be_empty
end
it 'has required keys for each embed type' do
config.each do |embed_type, embed_config|
expect(embed_config).to have_key('regex'), "#{embed_type} missing regex"
expect(embed_config).to have_key('template'), "#{embed_type} missing template"
expect(embed_config['regex']).to be_a(String), "#{embed_type} regex should be string"
expect(embed_config['template']).to be_a(String), "#{embed_type} template should be string"
end
end
it 'contains expected embed types' do
expected_types = %w[youtube loom vimeo mp4 arcade wistia bunny codepen github_gist]
expect(config.keys).to match_array(expected_types)
end
end
describe 'regex patterns and named capture groups' do
let(:test_cases) do
{
'youtube' => [
{ url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', expected: { 'video_id' => 'dQw4w9WgXcQ' } },
{ url: 'https://youtu.be/dQw4w9WgXcQ', expected: { 'video_id' => 'dQw4w9WgXcQ' } },
{ url: 'https://youtube.com/watch?v=abc123XYZ', expected: { 'video_id' => 'abc123XYZ' } }
],
'loom' => [
{ url: 'https://www.loom.com/share/abc123def456', expected: { 'video_id' => 'abc123def456' } },
{ url: 'https://loom.com/share/xyz789', expected: { 'video_id' => 'xyz789' } }
],
'vimeo' => [
{ url: 'https://vimeo.com/123456789', expected: { 'video_id' => '123456789' } },
{ url: 'https://www.vimeo.com/987654321', expected: { 'video_id' => '987654321' } }
],
'mp4' => [
{ url: 'https://example.com/video.mp4', expected: { 'link_url' => 'https://example.com/video.mp4' } },
{ url: 'https://www.test.com/path/to/movie.mp4', expected: { 'link_url' => 'https://www.test.com/path/to/movie.mp4' } }
],
'arcade' => [
{ url: 'https://app.arcade.software/share/arcade123', expected: { 'video_id' => 'arcade123' } },
{ url: 'https://www.app.arcade.software/share/demo456', expected: { 'video_id' => 'demo456' } }
],
'wistia' => [
{ url: 'https://chatwoot.wistia.com/medias/kjwjeq6f9i', expected: { 'video_id' => 'kjwjeq6f9i' } },
{ url: 'https://www.company.wistia.com/medias/abc123def', expected: { 'video_id' => 'abc123def' } }
],
'bunny' => [
{ url: 'https://iframe.mediadelivery.net/play/431789/1f105841-cad9-46fe-a70e-b7623c60797c',
expected: { 'library_id' => '431789', 'video_id' => '1f105841-cad9-46fe-a70e-b7623c60797c' } },
{ url: 'https://iframe.mediadelivery.net/play/12345/abcdef-ghijkl', expected: { 'library_id' => '12345', 'video_id' => 'abcdef-ghijkl' } }
],
'codepen' => [
{ url: 'https://codepen.io/username/pen/abcdef', expected: { 'user' => 'username', 'pen_id' => 'abcdef' } },
{ url: 'https://www.codepen.io/testuser/pen/xyz123', expected: { 'user' => 'testuser', 'pen_id' => 'xyz123' } }
],
'github_gist' => [
{ url: 'https://gist.github.com/username/1234567890abcdef1234567890abcdef',
expected: { 'username' => 'username', 'gist_id' => '1234567890abcdef1234567890abcdef' } },
{ url: 'https://gist.github.com/testuser/fedcba0987654321fedcba0987654321', expected: { 'username' => 'testuser', 'gist_id' => 'fedcba0987654321fedcba0987654321' } }
]
}
end
it 'correctly captures named groups for all embed types' do
test_cases.each do |embed_type, cases|
regex = Regexp.new(config[embed_type]['regex'])
cases.each do |test_case|
match = regex.match(test_case[:url])
expect(match).not_to be_nil, "#{embed_type} regex failed to match URL: #{test_case[:url]}"
expect(match.named_captures).to eq(test_case[:expected]),
"#{embed_type} captured groups don't match expected for URL: #{test_case[:url]}"
end
end
end
it 'validates that template variables match capture group names' do
config.each do |embed_type, embed_config|
regex = Regexp.new(embed_config['regex'])
template = embed_config['template']
# Extract template variables like %{video_id}
template_vars = template.scan(/%\{(\w+)\}/).flatten.uniq
# Get named capture groups from regex
capture_names = regex.names
expect(capture_names).to match_array(template_vars),
"#{embed_type}: Template variables #{template_vars} don't match capture groups #{capture_names}"
end
end
end
end
@@ -262,55 +262,4 @@ RSpec.describe 'Agent Bot API', type: :request do
end end
end end
end end
describe 'POST /api/v1/accounts/{account.id}/agent_bots/:id/reset_access_token' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/agent_bots/#{agent_bot.id}/reset_access_token"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
it 'regenerates the access token when administrator' do
old_token = agent_bot.access_token.token
post "/api/v1/accounts/#{account.id}/agent_bots/#{agent_bot.id}/reset_access_token",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
agent_bot.reload
expect(agent_bot.access_token.token).not_to eq(old_token)
json_response = response.parsed_body
expect(json_response['access_token']).to eq(agent_bot.access_token.token)
end
it 'would not reset the access token when agent' do
old_token = agent_bot.access_token.token
post "/api/v1/accounts/#{account.id}/agent_bots/#{agent_bot.id}/reset_access_token",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
agent_bot.reload
expect(agent_bot.access_token.token).to eq(old_token)
end
it 'would not reset access token for a global agent bot' do
global_bot = create(:agent_bot)
old_token = global_bot.access_token.token
post "/api/v1/accounts/#{account.id}/agent_bots/#{global_bot.id}/reset_access_token",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:not_found)
global_bot.reload
expect(global_bot.access_token.token).to eq(old_token)
end
end
end
end end
@@ -296,32 +296,4 @@ RSpec.describe 'Profile API', type: :request do
end end
end end
end end
describe 'POST /api/v1/profile/reset_access_token' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
post '/api/v1/profile/reset_access_token'
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
let(:agent) { create(:user, account: account, role: :agent) }
it 'regenerates the access token' do
old_token = agent.access_token.token
post '/api/v1/profile/reset_access_token',
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
agent.reload
expect(agent.access_token.token).not_to eq(old_token)
json_response = response.parsed_body
expect(json_response['access_token']).to eq(agent.access_token.token)
end
end
end
end end
@@ -13,17 +13,12 @@ RSpec.describe 'Google::CallbacksController', type: :request do
describe 'GET /google/callback' do describe 'GET /google/callback' do
let(:response_body_success) do let(:response_body_success) do
{ id_token: JWT.encode({ email: email, name: 'test' }, false), access_token: SecureRandom.hex(10), token_type: 'Bearer', { id_token: JWT.encode({ email: email, name: 'test' }, false), access_token: SecureRandom.hex(10), token_type: 'Bearer',
refresh_token: SecureRandom.hex(10), scope: 'https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email https://mail.google.com/' } refresh_token: SecureRandom.hex(10) }
end end
let(:response_body_success_without_name) do let(:response_body_success_without_name) do
{ id_token: JWT.encode({ email: email }, false), access_token: SecureRandom.hex(10), token_type: 'Bearer', { id_token: JWT.encode({ email: email }, false), access_token: SecureRandom.hex(10), token_type: 'Bearer',
refresh_token: SecureRandom.hex(10), scope: 'https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email https://mail.google.com/' } refresh_token: SecureRandom.hex(10) }
end
let(:response_body_insufficient_scopes) do
{ id_token: JWT.encode({ email: email, name: 'test' }, false), access_token: SecureRandom.hex(10), token_type: 'Bearer',
refresh_token: SecureRandom.hex(10), scope: 'https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email' }
end end
it 'creates inboxes if authentication is successful' do it 'creates inboxes if authentication is successful' do
@@ -78,20 +73,7 @@ RSpec.describe 'Google::CallbacksController', type: :request do
expect(inbox.name).to eq email.split('@').first.parameterize.titleize expect(inbox.name).to eq email.split('@').first.parameterize.titleize
end end
it 'redirects to custom error URL when insufficient scopes are granted' do it 'redirects to google app in case of error' do
stub_request(:post, 'https://accounts.google.com/o/oauth2/token')
.with(body: { 'code' => code, 'grant_type' => 'authorization_code',
'redirect_uri' => "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/google/callback" })
.to_return(status: 200, body: response_body_insufficient_scopes.to_json, headers: { 'Content-Type' => 'application/json' })
get google_callback_url, params: { code: code }
expect(response).to redirect_to(/#{Regexp.escape("#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/app/accounts/#{account.id}/settings/inboxes/new/email")}/)
expect(response.location).to include('error=')
expect(Redis::Alfred.get(cache_key).to_i).to eq account.id
end
it 'redirects to custom error URL in case of OAuth error' do
stub_request(:post, 'https://accounts.google.com/o/oauth2/token') stub_request(:post, 'https://accounts.google.com/o/oauth2/token')
.with(body: { 'code' => code, 'grant_type' => 'authorization_code', .with(body: { 'code' => code, 'grant_type' => 'authorization_code',
'redirect_uri' => "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/google/callback" }) 'redirect_uri' => "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/google/callback" })
@@ -99,8 +81,7 @@ RSpec.describe 'Google::CallbacksController', type: :request do
get google_callback_url, params: { code: code } get google_callback_url, params: { code: code }
expect(response).to redirect_to(/#{Regexp.escape("#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/app/accounts/#{account.id}/settings/inboxes/new/email")}/) expect(response).to redirect_to '/'
expect(response.location).to include('error=')
expect(Redis::Alfred.get(cache_key).to_i).to eq account.id expect(Redis::Alfred.get(cache_key).to_i).to eq account.id
end end
end end
@@ -82,7 +82,7 @@ RSpec.describe 'Public Articles API', type: :request do
get "/hc/#{portal.slug}/articles/#{article.slug}" get "/hc/#{portal.slug}/articles/#{article.slug}"
expect(response).to have_http_status(:success) expect(response).to have_http_status(:success)
expect(response.body).to include(ChatwootMarkdownRenderer.new(article.content).render_article) expect(response.body).to include(ChatwootMarkdownRenderer.new(article.content).render_article)
expect(article.reload.views).to eq 0 # View count should not increment on show expect(article.reload.views).to eq 1
end end
it 'does not increment the view count if the article is not published' do it 'does not increment the view count if the article is not published' do
@@ -98,42 +98,4 @@ RSpec.describe 'Public Articles API', type: :request do
expect(response).to have_http_status(:success) expect(response).to have_http_status(:success)
end end
end end
describe 'GET /public/api/v1/portals/:slug/articles/:slug.png (tracking pixel)' do
it 'serves a PNG image and increments view count for published article' do
get "/hc/#{portal.slug}/articles/#{article.slug}.png"
expect(response).to have_http_status(:success)
expect(response.headers['Content-Type']).to eq('image/png')
expect(response.headers['Cache-Control']).to include('max-age=86400')
expect(response.headers['Cache-Control']).to include('private')
expect(article.reload.views).to eq 1
end
it 'serves a PNG image but does not increment view count for draft article' do
draft_article = create(:article, category: category, status: :draft, portal: portal, account_id: account.id, author_id: agent.id, views: 0)
get "/hc/#{portal.slug}/articles/#{draft_article.slug}.png"
expect(response).to have_http_status(:success)
expect(response.headers['Content-Type']).to eq('image/png')
expect(response.headers['Cache-Control']).to include('max-age=86400')
expect(response.headers['Cache-Control']).to include('private')
expect(draft_article.reload.views).to eq 0
end
it 'returns 404 if article does not exist' do
get "/hc/#{portal.slug}/articles/non-existent-article.png"
expect(response).to have_http_status(:not_found)
end
it 'sets proper cache headers for performance' do
get "/hc/#{portal.slug}/articles/#{article.slug}.png"
expect(response.headers['Cache-Control']).to include('max-age=86400')
expect(response.headers['Cache-Control']).to include('private')
expect(response.headers['Content-Type']).to eq('image/png')
end
end
end end
@@ -1,26 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Enterprise::CsatSurveyResponsePolicy', type: :policy do
subject(:csat_policy) { CsatSurveyResponsePolicy }
let(:account) { create(:account) }
let(:csat_survey_response) { create(:csat_survey_response, account: account) }
# Create a custom role with report_manage permission
let(:custom_role) { create(:custom_role, account: account, permissions: ['report_manage']) }
let(:agent_with_role) { create(:user) } # Create without account
let(:agent_with_role_account_user) do
create(:account_user, user: agent_with_role, account: account, role: :agent, custom_role: custom_role)
end
let(:agent_with_role_context) do
{ user: agent_with_role, account: account, account_user: agent_with_role_account_user }
end
permissions :index?, :metrics?, :download? do
context 'when agent with report_manage permission' do
it { expect(csat_policy).to permit(agent_with_role_context, csat_survey_response) }
end
end
end
+14
View File
@@ -435,6 +435,20 @@ RSpec.describe Conversation do
end end
end end
describe '#create_csat_not_sent_activity_message' do
subject(:create_csat_not_sent_activity_message) { conversation.create_csat_not_sent_activity_message }
let(:conversation) { create(:conversation) }
it 'creates CSAT not sent activity message' do
create_csat_not_sent_activity_message
expect(Conversations::ActivityMessageJob)
.to(have_been_enqueued.at_least(:once).with(conversation, { account_id: conversation.account_id, inbox_id: conversation.inbox_id,
message_type: :activity,
content: 'CSAT survey not sent due to outgoing message restrictions' }))
end
end
describe 'unread_messages' do describe 'unread_messages' do
subject(:unread_messages) { conversation.unread_messages } subject(:unread_messages) { conversation.unread_messages }
@@ -1,25 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe CsatSurveyResponsePolicy, type: :policy do
subject(:csat_policy) { described_class }
let(:account) { create(:account) }
let(:administrator) { create(:user, :administrator, account: account) }
let(:agent) { create(:user, account: account) }
let(:csat_survey_response) { create(:csat_survey_response, account: account) }
let(:administrator_context) { { user: administrator, account: account, account_user: account.account_users.first } }
let(:agent_context) { { user: agent, account: account, account_user: account.account_users.last } }
permissions :index?, :metrics?, :download? do
context 'when administrator' do
it { expect(csat_policy).to permit(administrator_context, csat_survey_response) }
end
context 'when agent' do
it { expect(csat_policy).not_to permit(agent_context, csat_survey_response) }
end
end
end
@@ -121,8 +121,9 @@ describe MessageTemplates::HookExecutionService do
create(:message, conversation: conversation, message_type: 'incoming') create(:message, conversation: conversation, message_type: 'incoming')
end end
it 'calls ::MessageTemplates::Template::CsatSurvey when a conversation is resolved in an inbox with survey enabled' do it 'calls ::MessageTemplates::Template::CsatSurvey when a conversation is resolved in an inbox with survey enabled and can reply' do
conversation.inbox.update(csat_survey_enabled: true) conversation.inbox.update(csat_survey_enabled: true)
allow(conversation).to receive(:can_reply?).and_return(true)
conversation.resolved! conversation.resolved!
Conversations::ActivityMessageJob.perform_now(conversation, Conversations::ActivityMessageJob.perform_now(conversation,
@@ -172,6 +173,32 @@ describe MessageTemplates::HookExecutionService do
expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new).with(conversation: conversation) expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new).with(conversation: conversation)
expect(csat_survey).not_to have_received(:perform) expect(csat_survey).not_to have_received(:perform)
end end
it 'will not call ::MessageTemplates::Template::CsatSurvey if cannot reply' do
conversation.inbox.update(csat_survey_enabled: true)
allow(conversation).to receive(:can_reply?).and_return(false)
conversation.resolved!
Conversations::ActivityMessageJob.perform_now(conversation,
{ account_id: conversation.account_id, inbox_id: conversation.inbox_id, message_type: :activity,
content: 'Conversation marked resolved!!' })
expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new).with(conversation: conversation)
expect(csat_survey).not_to have_received(:perform)
end
it 'creates activity message when CSAT not sent due to messaging window restriction' do
conversation.inbox.update(csat_survey_enabled: true)
allow(conversation).to receive(:can_reply?).and_return(false)
allow(conversation).to receive(:create_csat_not_sent_activity_message)
conversation.resolved!
Conversations::ActivityMessageJob.perform_now(conversation,
{ account_id: conversation.account_id, inbox_id: conversation.inbox_id, message_type: :activity,
content: 'Conversation marked resolved!!' })
expect(conversation).to have_received(:create_csat_not_sent_activity_message)
end
end end
context 'when it is after working hours' do context 'when it is after working hours' do