Merge branch 'develop' into feat/whatsapp-embedded-signup
This commit is contained in:
@@ -29,6 +29,11 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
|
||||
head :ok
|
||||
end
|
||||
|
||||
def reset_access_token
|
||||
@agent_bot.access_token.regenerate_token
|
||||
@agent_bot.reload
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def agent_bot
|
||||
|
||||
@@ -38,6 +38,11 @@ class Api::V1::ProfilesController < Api::BaseController
|
||||
head :ok
|
||||
end
|
||||
|
||||
def reset_access_token
|
||||
@user.access_token.regenerate_token
|
||||
@user.reload
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_user
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController
|
||||
before_action :ensure_custom_domain_request, only: [:show, :index]
|
||||
before_action :portal
|
||||
before_action :set_category, except: [:index, :show]
|
||||
before_action :set_category, except: [:index, :show, :tracking_pixel]
|
||||
before_action :set_article, only: [:show]
|
||||
layout 'portal'
|
||||
|
||||
@@ -15,6 +15,21 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
|
||||
|
||||
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
|
||||
|
||||
def limit_results
|
||||
@@ -39,7 +54,6 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
|
||||
|
||||
def set_article
|
||||
@article = @portal.articles.find_by(slug: permitted_params[:article_slug])
|
||||
@article.increment_view_count if @article.published?
|
||||
@parsed_content = render_article_content(@article.content)
|
||||
end
|
||||
|
||||
|
||||
@@ -21,6 +21,10 @@ class AgentBotsAPI extends ApiClient {
|
||||
deleteAgentBotAvatar(botId) {
|
||||
return axios.delete(`${this.url}/${botId}/avatar`);
|
||||
}
|
||||
|
||||
resetAccessToken(botId) {
|
||||
return axios.post(`${this.url}/${botId}/reset_access_token`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new AgentBotsAPI();
|
||||
|
||||
@@ -102,4 +102,8 @@ export default {
|
||||
const urlData = endPoints('resendConfirmation');
|
||||
return axios.post(urlData.url);
|
||||
},
|
||||
resetAccessToken() {
|
||||
const urlData = endPoints('resetAccessToken');
|
||||
return axios.post(urlData.url);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -51,6 +51,9 @@ const endPoints = {
|
||||
resendConfirmation: {
|
||||
url: '/api/v1/profile/resend_confirmation',
|
||||
},
|
||||
resetAccessToken: {
|
||||
url: '/api/v1/profile/reset_access_token',
|
||||
},
|
||||
};
|
||||
|
||||
export default page => {
|
||||
|
||||
@@ -9,5 +9,6 @@ describe('#AgentBotsAPI', () => {
|
||||
expect(AgentBotsAPI).toHaveProperty('create');
|
||||
expect(AgentBotsAPI).toHaveProperty('update');
|
||||
expect(AgentBotsAPI).toHaveProperty('delete');
|
||||
expect(AgentBotsAPI).toHaveProperty('resetAccessToken');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<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>
|
||||
@@ -0,0 +1,99 @@
|
||||
<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>
|
||||
@@ -129,6 +129,7 @@ export function usePolicy() {
|
||||
return {
|
||||
checkPermissions,
|
||||
shouldShowPaywall,
|
||||
isFeatureFlagEnabled,
|
||||
shouldShow,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -62,7 +62,9 @@
|
||||
"ACCESS_TOKEN": {
|
||||
"TITLE": "Access Token",
|
||||
"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": {
|
||||
"AVATAR": {
|
||||
|
||||
@@ -76,7 +76,12 @@
|
||||
"ACCESS_TOKEN": {
|
||||
"TITLE": "Access Token",
|
||||
"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": {
|
||||
"TITLE": "Audio Alerts",
|
||||
|
||||
@@ -10,10 +10,8 @@ import {
|
||||
CONTACT_PERMISSIONS,
|
||||
PORTAL_PERMISSIONS,
|
||||
} from 'dashboard/constants/permissions.js';
|
||||
import {
|
||||
getUserPermissions,
|
||||
filterItemsByPermission,
|
||||
} from 'dashboard/helper/permissionsHelper.js';
|
||||
import { usePolicy } from 'dashboard/composables/usePolicy';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
|
||||
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
@@ -39,8 +37,6 @@ const pages = ref({
|
||||
articles: 1,
|
||||
});
|
||||
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||
const contactRecords = useMapGetter('conversationSearch/getContactRecords');
|
||||
const conversationRecords = useMapGetter(
|
||||
'conversationSearch/getConversationRecords'
|
||||
@@ -83,9 +79,7 @@ const filterConversations = filterByTab('conversations');
|
||||
const filterMessages = filterByTab('messages');
|
||||
const filterArticles = filterByTab('articles');
|
||||
|
||||
const userPermissions = computed(() =>
|
||||
getUserPermissions(currentUser.value, currentAccountId.value)
|
||||
);
|
||||
const { shouldShow, isFeatureFlagEnabled } = usePolicy();
|
||||
|
||||
const TABS_CONFIG = {
|
||||
all: {
|
||||
@@ -111,47 +105,67 @@ const TABS_CONFIG = {
|
||||
},
|
||||
articles: {
|
||||
permissions: [...ROLES, PORTAL_PERMISSIONS],
|
||||
featureFlag: FEATURE_FLAGS.HELP_CENTER,
|
||||
count: () => mappedArticles.value.length,
|
||||
},
|
||||
};
|
||||
|
||||
const tabs = computed(() => {
|
||||
const configs = Object.entries(TABS_CONFIG).map(([key, config]) => ({
|
||||
key,
|
||||
name: t(`SEARCH.TABS.${key.toUpperCase()}`),
|
||||
count: config.count(),
|
||||
showBadge: key !== 'all',
|
||||
permissions: config.permissions,
|
||||
}));
|
||||
|
||||
return filterItemsByPermission(
|
||||
configs,
|
||||
userPermissions.value,
|
||||
item => item.permissions
|
||||
);
|
||||
return Object.entries(TABS_CONFIG)
|
||||
.map(([key, config]) => ({
|
||||
key,
|
||||
name: t(`SEARCH.TABS.${key.toUpperCase()}`),
|
||||
count: config.count(),
|
||||
showBadge: key !== 'all',
|
||||
permissions: config.permissions,
|
||||
featureFlag: config.featureFlag,
|
||||
}))
|
||||
.filter(config => {
|
||||
// why the double check, glad you asked.
|
||||
// Some features are marked as premium features, that means
|
||||
// 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 permissionCounts = {
|
||||
contacts: {
|
||||
const permissionCounts = [
|
||||
{
|
||||
permissions: [...ROLES, CONTACT_PERMISSIONS],
|
||||
count: () => contacts.value.length,
|
||||
},
|
||||
conversations: {
|
||||
{
|
||||
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
|
||||
count: () => conversations.value.length + messages.value.length,
|
||||
},
|
||||
articles: {
|
||||
{
|
||||
permissions: [...ROLES, PORTAL_PERMISSIONS],
|
||||
featureFlag: FEATURE_FLAGS.HELP_CENTER,
|
||||
count: () => articles.value.length,
|
||||
},
|
||||
};
|
||||
return filterItemsByPermission(
|
||||
permissionCounts,
|
||||
userPermissions.value,
|
||||
item => item.permissions,
|
||||
(_, item) => item.count
|
||||
).reduce((total, count) => total + count(), 0);
|
||||
];
|
||||
|
||||
return permissionCounts
|
||||
.filter(config => {
|
||||
// why the double check, glad you asked.
|
||||
// Some features are marked as premium features, that means
|
||||
// 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)
|
||||
);
|
||||
})
|
||||
.map(config => {
|
||||
return config.count();
|
||||
})
|
||||
.reduce((sum, count) => sum + count, 0);
|
||||
});
|
||||
|
||||
const activeTabIndex = computed(() => {
|
||||
@@ -355,7 +369,9 @@ onUnmounted(() => {
|
||||
</Policy>
|
||||
|
||||
<Policy
|
||||
v-if="isFeatureFlagEnabled(FEATURE_FLAGS.HELP_CENTER)"
|
||||
:permissions="[...ROLES, PORTAL_PERMISSIONS]"
|
||||
:feature-flag="FEATURE_FLAGS.HELP_CENTER"
|
||||
class="flex flex-col justify-center"
|
||||
>
|
||||
<SearchResultArticlesList
|
||||
|
||||
+26
-2
@@ -228,7 +228,20 @@ const initializeForm = () => {
|
||||
|
||||
const onCopyToken = async value => {
|
||||
await copyTextToClipboard(value);
|
||||
useAlert(t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
|
||||
useAlert(t('AGENT_BOTS.ACCESS_TOKEN.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 = () => {
|
||||
@@ -312,7 +325,18 @@ defineExpose({ dialogRef });
|
||||
>
|
||||
{{ $t('AGENT_BOTS.ACCESS_TOKEN.TITLE') }}
|
||||
</label>
|
||||
<AccessToken :value="accessToken" @on-copy="onCopyToken" />
|
||||
<AccessToken
|
||||
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 class="flex items-center justify-end w-full gap-2 px-0 py-2">
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import FormButton from 'v3/components/Form/Button.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import ConfirmButton from 'dashboard/components-next/button/ConfirmButton.vue';
|
||||
|
||||
const props = defineProps({
|
||||
value: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
value: { type: String, default: '' },
|
||||
showResetButton: { type: Boolean, default: true },
|
||||
});
|
||||
const emit = defineEmits(['onCopy']);
|
||||
|
||||
const emit = defineEmits(['onCopy', 'onReset']);
|
||||
|
||||
const inputType = ref('password');
|
||||
|
||||
const toggleMasked = () => {
|
||||
inputType.value = inputType.value === 'password' ? 'text' : 'password';
|
||||
};
|
||||
@@ -20,6 +23,10 @@ const maskIcon = computed(() => {
|
||||
const onClick = () => {
|
||||
emit('onCopy', props.value);
|
||||
};
|
||||
|
||||
const onReset = () => {
|
||||
emit('onReset');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -38,7 +45,7 @@ const onClick = () => {
|
||||
>
|
||||
<template #masked>
|
||||
<button
|
||||
class="absolute top-1.5 ltr:right-0.5 rtl:left-0.5"
|
||||
class="absolute top-0 bottom-0 ltr:right-0.5 rtl:left-0.5"
|
||||
type="button"
|
||||
@click="toggleMasked"
|
||||
>
|
||||
@@ -46,15 +53,28 @@ const onClick = () => {
|
||||
</button>
|
||||
</template>
|
||||
</woot-input>
|
||||
<FormButton
|
||||
type="button"
|
||||
size="large"
|
||||
icon="text-copy"
|
||||
variant="outline"
|
||||
color-scheme="secondary"
|
||||
@click="onClick"
|
||||
>
|
||||
{{ $t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.COPY') }}
|
||||
</FormButton>
|
||||
<div class="flex flex-row gap-2">
|
||||
<NextButton
|
||||
:label="$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.COPY')"
|
||||
slate
|
||||
outline
|
||||
type="button"
|
||||
icon="i-lucide-copy"
|
||||
class="rounded-xl"
|
||||
@click="onClick"
|
||||
/>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
@@ -181,6 +181,14 @@ export default {
|
||||
await copyTextToClipboard(value);
|
||||
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>
|
||||
@@ -281,7 +289,11 @@ export default {
|
||||
)
|
||||
"
|
||||
>
|
||||
<AccessToken :value="currentUser.access_token" @on-copy="onCopyToken" />
|
||||
<AccessToken
|
||||
:value="currentUser.access_token"
|
||||
@on-copy="onCopyToken"
|
||||
@on-reset="resetAccessToken"
|
||||
/>
|
||||
</FormSection>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -172,6 +172,17 @@ export const actions = {
|
||||
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 = {
|
||||
|
||||
@@ -213,6 +213,16 @@ 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 () => {
|
||||
try {
|
||||
await authAPI.resendConfirmation();
|
||||
|
||||
@@ -170,4 +170,21 @@ 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,4 +228,20 @@ 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,4 +22,8 @@ class AgentBotPolicy < ApplicationPolicy
|
||||
def avatar?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def reset_access_token?
|
||||
@account_user.administrator?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
json.partial! 'api/v1/models/agent_bot', formats: [:json], resource: AgentBotPresenter.new(@agent_bot)
|
||||
@@ -0,0 +1 @@
|
||||
json.partial! 'api/v1/models/user', formats: [:json], resource: @user
|
||||
@@ -73,6 +73,30 @@ By default, it renders:
|
||||
<% end %>
|
||||
</main>
|
||||
</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>
|
||||
<style>
|
||||
html.dark {
|
||||
|
||||
@@ -67,6 +67,7 @@ Rails.application.routes.draw do
|
||||
end
|
||||
resources :agent_bots, only: [:index, :create, :show, :update, :destroy] do
|
||||
delete :avatar, on: :member
|
||||
post :reset_access_token, on: :member
|
||||
end
|
||||
resources :contact_inboxes, only: [] do
|
||||
collection do
|
||||
@@ -296,6 +297,7 @@ Rails.application.routes.draw do
|
||||
post :auto_offline
|
||||
put :set_active_account
|
||||
post :resend_confirmation
|
||||
post :reset_access_token
|
||||
end
|
||||
end
|
||||
|
||||
@@ -447,6 +449,7 @@ Rails.application.routes.draw do
|
||||
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/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'
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 123 B |
@@ -262,4 +262,55 @@ RSpec.describe 'Agent Bot API', type: :request do
|
||||
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
|
||||
|
||||
@@ -296,4 +296,32 @@ RSpec.describe 'Profile API', type: :request do
|
||||
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
|
||||
|
||||
@@ -82,7 +82,7 @@ RSpec.describe 'Public Articles API', type: :request do
|
||||
get "/hc/#{portal.slug}/articles/#{article.slug}"
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.body).to include(ChatwootMarkdownRenderer.new(article.content).render_article)
|
||||
expect(article.reload.views).to eq 1
|
||||
expect(article.reload.views).to eq 0 # View count should not increment on show
|
||||
end
|
||||
|
||||
it 'does not increment the view count if the article is not published' do
|
||||
@@ -98,4 +98,42 @@ RSpec.describe 'Public Articles API', type: :request do
|
||||
expect(response).to have_http_status(:success)
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user