Merge branch 'develop' into feat/email-forwarding

This commit is contained in:
Sivin Varghese
2025-05-21 23:28:22 +05:30
committed by GitHub
72 changed files with 1599 additions and 581 deletions
@@ -0,0 +1,65 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import Button from 'dashboard/components-next/button/Button.vue';
import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue';
defineProps({
selectedContact: {
type: Object,
required: true,
},
});
const { t } = useI18n();
const [showDeleteSection, toggleDeleteSection] = useToggle();
const confirmDeleteContactDialogRef = ref(null);
const openConfirmDeleteContactDialog = () => {
confirmDeleteContactDialogRef.value?.dialogRef.open();
};
</script>
<template>
<div class="flex flex-col items-start border-t border-n-strong px-6 py-5">
<Button
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
sm
link
slate
class="hover:!no-underline text-n-slate-12"
icon="i-lucide-chevron-down"
trailing-icon
@click="toggleDeleteSection()"
/>
<div
class="transition-all duration-300 ease-in-out grid w-full overflow-hidden"
:class="
showDeleteSection
? 'grid-rows-[1fr] opacity-100 mt-2'
: 'grid-rows-[0fr] opacity-0 mt-0'
"
>
<div class="overflow-hidden min-h-0">
<span class="inline-flex text-n-slate-11 text-sm items-center gap-1">
{{ t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.MESSAGE') }}
<Button
:label="t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.BUTTON')"
sm
ruby
link
@click="openConfirmDeleteContactDialog()"
/>
</span>
</div>
</div>
</div>
<ConfirmContactDeleteDialog
ref="confirmDeleteContactDialogRef"
:selected-contact="selectedContact"
/>
</template>
@@ -7,6 +7,7 @@ import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/Contac
import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Flag from 'dashboard/components-next/flag/Flag.vue';
import ContactDeleteSection from 'dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue';
import countries from 'shared/constants/countries';
const props = defineProps({
@@ -149,15 +150,15 @@ const onClickViewDetails = () => emit('showContact', props.id);
/>
<template #after>
<transition
enter-active-class="overflow-hidden transition-all duration-300 ease-out"
leave-active-class="overflow-hidden transition-all duration-300 ease-in"
enter-from-class="overflow-hidden opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-[690px] sm:max-h-[470px] md:max-h-[410px]"
leave-from-class="opacity-100 max-h-[690px] sm:max-h-[470px] md:max-h-[410px]"
leave-to-class="overflow-hidden opacity-0 max-h-0"
<div
class="transition-all duration-500 ease-in-out grid overflow-hidden"
:class="
isExpanded
? 'grid-rows-[1fr] opacity-100'
: 'grid-rows-[0fr] opacity-0'
"
>
<div v-show="isExpanded" class="w-full">
<div class="overflow-hidden">
<div class="flex flex-col gap-6 p-6 border-t border-n-strong">
<ContactsForm
ref="contactsFormRef"
@@ -176,8 +177,14 @@ const onClickViewDetails = () => emit('showContact', props.id);
/>
</div>
</div>
<ContactDeleteSection
:selected-contact="{
id: props.id,
name: props.name,
}"
/>
</div>
</transition>
</div>
</template>
</CardLayout>
</template>
@@ -47,11 +47,7 @@ defineExpose({ dialogRef });
ref="dialogRef"
type="alert"
:title="t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.TITLE')"
:description="
t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.DESCRIPTION', {
contactName: props.selectedContact.name,
})
"
:description="t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.DESCRIPTION')"
:confirm-button-label="t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.CONFIRM')"
@confirm="handleDialogConfirm"
/>
@@ -117,7 +117,7 @@ const STYLE_CONFIG = {
'text-n-ruby-11 hover:enabled:bg-n-ruby-9/10 focus-visible:bg-n-ruby-9/10 outline-n-ruby-8',
ghost:
'text-n-ruby-11 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 outline-transparent',
link: 'text-n-ruby-9 hover:enabled:underline focus-visible:underline outline-transparent',
link: 'text-n-ruby-9 dark:text-n-ruby-11 hover:enabled:underline focus-visible:underline outline-transparent',
},
amber: {
solid:
@@ -0,0 +1,21 @@
<script setup>
import CopilotHeader from './CopilotHeader.vue';
</script>
<template>
<Story
title="Captain/Copilot/CopilotHeader"
:layout="{ type: 'grid', width: '800px' }"
>
<!-- Default State -->
<Variant title="Default State">
<CopilotHeader />
</Variant>
<!-- With New Conversation Button -->
<Variant title="With New Conversation Button">
<!-- eslint-disable-next-line vue/prefer-true-attribute-shorthand -->
<CopilotHeader :has-messages="true" />
</Variant>
</Story>
</template>
@@ -0,0 +1,32 @@
<script setup>
import Button from '../button/Button.vue';
defineProps({
hasMessages: {
type: Boolean,
default: false,
},
});
defineEmits(['reset', 'close']);
</script>
<template>
<div
class="flex items-center justify-between px-5 py-2 border-b border-n-weak h-12"
>
<div class="flex items-center justify-between gap-2 flex-1">
<span class="font-medium text-sm text-n-slate-12">
{{ $t('CAPTAIN.COPILOT.TITLE') }}
</span>
<div class="flex items-center">
<Button
v-if="hasMessages"
icon="i-lucide-plus"
ghost
sm
@click="$emit('reset')"
/>
<Button icon="i-lucide-x" ghost sm @click="$emit('close')" />
</div>
</div>
</div>
</template>
@@ -13,19 +13,16 @@ const sendMessage = () => {
</script>
<template>
<form
class="border border-n-weak bg-n-alpha-3 rounded-lg h-12 flex"
@submit.prevent="sendMessage"
>
<form class="relative" @submit.prevent="sendMessage">
<input
v-model="message"
type="text"
:placeholder="$t('CAPTAIN.COPILOT.SEND_MESSAGE')"
class="w-full reset-base bg-transparent px-4 py-3 text-n-slate-11 text-sm"
class="w-full reset-base bg-n-alpha-3 ltr:pl-4 ltr:pr-12 rtl:pl-12 rtl:pr-4 py-3 text-n-slate-11 text-sm border border-n-weak rounded-lg focus:outline-none focus:ring-1 focus:ring-n-blue-11 focus:border-n-blue-11"
@keyup.enter="sendMessage"
/>
<button
class="h-auto w-12 flex items-center justify-center text-n-slate-11"
class="absolute ltr:right-1 rtl:left-1 top-1/2 -translate-y-1/2 h-9 w-10 flex items-center justify-center text-n-slate-11 hover:text-n-blue-11"
type="submit"
>
<i class="i-ph-arrow-up" />
@@ -0,0 +1,25 @@
<script setup>
import Icon from '../../components-next/icon/Icon.vue';
defineProps({
content: {
type: String,
required: true,
},
});
</script>
<template>
<div
class="flex flex-col gap-2 p-3 rounded-lg bg-n-background/50 border border-n-weak hover:bg-n-background/80 transition-colors duration-200"
>
<div class="flex items-start gap-2">
<Icon
icon="i-lucide-sparkles"
class="w-4 h-4 mt-0.5 flex-shrink-0 text-n-slate-9"
/>
<div class="text-sm text-n-slate-11">
{{ content }}
</div>
</div>
</div>
</template>
@@ -0,0 +1,34 @@
<script setup>
import CopilotThinkingGroup from './CopilotThinkingGroup.vue';
const messages = [
{
id: 1,
content: 'Analyzing the user query',
reasoning: 'Breaking down the request into actionable steps',
},
{
id: 2,
content: 'Searching codebase',
reasoning: 'Looking for relevant files and functions',
},
{
id: 3,
content: 'Generating response',
reasoning: 'Composing a helpful and accurate answer',
},
];
</script>
<template>
<Story title="Captain/Copilot/CopilotThinkingGroup" group="components">
<Variant title="Default">
<CopilotThinkingGroup :messages="messages" />
</Variant>
<Variant title="With Default Collapsed">
<!-- eslint-disable-next-line -->
<CopilotThinkingGroup :messages="messages" :default-collapsed="true" />
</Variant>
</Story>
</template>
@@ -0,0 +1,61 @@
<script setup>
import { ref, watch, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Icon from '../icon/Icon.vue';
import CopilotThinkingBlock from './CopilotThinkingBlock.vue';
const props = defineProps({
messages: { type: Array, required: true },
defaultCollapsed: { type: Boolean, default: false },
});
const { t } = useI18n();
const isExpanded = ref(!props.defaultCollapsed);
const thinkingCount = computed(() => props.messages.length);
watch(
() => props.defaultCollapsed,
newValue => {
if (newValue) {
isExpanded.value = false;
}
}
);
</script>
<template>
<div class="flex flex-col gap-2">
<button
class="group flex items-center gap-2 text-xs text-n-slate-10 hover:text-n-slate-11 transition-colors duration-200 -ml-3"
@click="isExpanded = !isExpanded"
>
<Icon
:icon="isExpanded ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'"
class="w-4 h-4 transition-transform duration-200 group-hover:scale-110"
/>
<span class="flex items-center gap-2">
{{ t('CAPTAIN.COPILOT.SHOW_STEPS') }}
<span
class="inline-flex items-center justify-center h-4 min-w-4 px-1 text-xs font-medium rounded-full bg-n-solid-3 text-n-slate-11"
>
{{ thinkingCount }}
</span>
</span>
</button>
<div
v-show="isExpanded"
class="space-y-3 transition-all duration-200"
:class="{
'opacity-100': isExpanded,
'opacity-0 max-h-0 overflow-hidden': !isExpanded,
}"
>
<CopilotThinkingBlock
v-for="message in messages"
:key="message.id"
:content="message.content"
:reasoning="message.reasoning"
/>
</div>
</div>
</template>
@@ -4,6 +4,7 @@ import { useMapGetter, useStore } from 'dashboard/composables/store';
import wootConstants from 'dashboard/constants/globals';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { useImpersonation } from 'dashboard/composables/useImpersonation';
import {
DropdownContainer,
@@ -20,6 +21,8 @@ const currentUserAvailability = useMapGetter('getCurrentUserAvailability');
const currentAccountId = useMapGetter('getCurrentAccountId');
const currentUserAutoOffline = useMapGetter('getCurrentUserAutoOffline');
const { isImpersonating } = useImpersonation();
const { AVAILABILITY_STATUS_KEYS } = wootConstants;
const statusList = computed(() => {
return [
@@ -46,6 +49,10 @@ const activeStatus = computed(() => {
});
function changeAvailabilityStatus(availability) {
if (isImpersonating.value) {
useAlert(t('PROFILE_SETTINGS.FORM.AVAILABILITY.IMPERSONATING_ERROR'));
return;
}
try {
store.dispatch('updateAvailability', {
availability,
@@ -1,6 +1,7 @@
<script>
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { useImpersonation } from 'dashboard/composables/useImpersonation';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
import WootDropdownHeader from 'shared/components/ui/dropdown/DropdownHeader.vue';
@@ -20,6 +21,10 @@ export default {
AvailabilityStatusBadge,
NextButton,
},
setup() {
const { isImpersonating } = useImpersonation();
return { isImpersonating };
},
data() {
return {
isStatusMenuOpened: false,
@@ -73,6 +78,13 @@ export default {
});
},
changeAvailabilityStatus(availability) {
if (this.isImpersonating) {
useAlert(
this.$t('PROFILE_SETTINGS.FORM.AVAILABILITY.IMPERSONATING_ERROR')
);
return;
}
if (this.isUpdating) {
return;
}
@@ -0,0 +1,37 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { useImpersonation } from '../useImpersonation';
vi.mock('shared/helpers/sessionStorage', () => ({
__esModule: true,
default: {
get: vi.fn(),
set: vi.fn(),
},
}));
import SessionStorage from 'shared/helpers/sessionStorage';
import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage';
describe('useImpersonation', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should return true if impersonation flag is set in session storage', () => {
SessionStorage.get.mockReturnValue(true);
const { isImpersonating } = useImpersonation();
expect(isImpersonating.value).toBe(true);
expect(SessionStorage.get).toHaveBeenCalledWith(
SESSION_STORAGE_KEYS.IMPERSONATION_USER
);
});
it('should return false if impersonation flag is not set in session storage', () => {
SessionStorage.get.mockReturnValue(false);
const { isImpersonating } = useImpersonation();
expect(isImpersonating.value).toBe(false);
expect(SessionStorage.get).toHaveBeenCalledWith(
SESSION_STORAGE_KEYS.IMPERSONATION_USER
);
});
});
@@ -0,0 +1,10 @@
import { computed } from 'vue';
import SessionStorage from 'shared/helpers/sessionStorage';
import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage';
export function useImpersonation() {
const isImpersonating = computed(() => {
return SessionStorage.get(SESSION_STORAGE_KEYS.IMPERSONATION_USER);
});
return { isImpersonating };
}
@@ -0,0 +1,3 @@
export const SESSION_STORAGE_KEYS = {
IMPERSONATION_USER: 'impersonationUser',
};
@@ -59,6 +59,11 @@
"ERROR_MESSAGE": "Could not update bot. Please try again."
}
},
"ACCESS_TOKEN": {
"TITLE": "Access Token",
"DESCRIPTION": "Copy the access token and save it securely",
"COPY_SUCCESSFUL": "Access token copied to clipboard"
},
"FORM": {
"AVATAR": {
"LABEL": "Bot avatar"
@@ -458,6 +458,10 @@
"PLACEHOLDER": "Add Twitter"
}
}
},
"DELETE_CONTACT": {
"MESSAGE": "This action is permanent and irreversible.",
"BUTTON": "Delete now"
}
},
"DETAILS": {
@@ -467,7 +471,7 @@
"DELETE_CONTACT": "Delete contact",
"DELETE_DIALOG": {
"TITLE": "Confirm Deletion",
"DESCRIPTION": "Are you sure you want to delete this {contactName} contact?",
"DESCRIPTION": "Are you sure you want to delete this contact?",
"CONFIRM": "Yes, Delete",
"API": {
"SUCCESS_MESSAGE": "Contact deleted successfully",
@@ -327,12 +327,14 @@
"NAME": "Captain",
"HEADER_KNOW_MORE": "Know more",
"COPILOT": {
"TITLE": "Copilot",
"SEND_MESSAGE": "Send message...",
"EMPTY_MESSAGE": "There was an error generating the response. Please try again.",
"LOADER": "Captain is thinking",
"YOU": "You",
"USE": "Use this",
"RESET": "Reset",
"SHOW_STEPS": "Show steps",
"SELECT_ASSISTANT": "Select Assistant",
"PROMPTS": {
"SUMMARIZE": {
@@ -185,7 +185,8 @@
"OFFLINE": "Offline"
},
"SET_AVAILABILITY_SUCCESS": "Availability has been set successfully",
"SET_AVAILABILITY_ERROR": "Couldn't set availability, please try again"
"SET_AVAILABILITY_ERROR": "Couldn't set availability, please try again",
"IMPERSONATING_ERROR": "Cannot change availability while impersonating a user"
},
"EMAIL": {
"LABEL": "Your email address",
@@ -5,12 +5,15 @@ import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { required, helpers, url } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { useToggle } from '@vueuse/core';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import AccessToken from 'dashboard/routes/dashboard/settings/profile/AccessToken.vue';
const props = defineProps({
type: {
@@ -42,6 +45,9 @@ const formState = reactive({
botAvatarUrl: '',
});
const [showAccessToken, toggleAccessToken] = useToggle();
const accessToken = ref('');
const v$ = useVuelidate(
{
botName: {
@@ -70,11 +76,22 @@ const isLoading = computed(() =>
: uiFlags.value.isUpdating
);
const dialogTitle = computed(() =>
props.type === MODAL_TYPES.CREATE
const dialogTitle = computed(() => {
if (showAccessToken.value) {
return t('AGENT_BOTS.ACCESS_TOKEN.TITLE');
}
return props.type === MODAL_TYPES.CREATE
? t('AGENT_BOTS.ADD.TITLE')
: t('AGENT_BOTS.EDIT.TITLE')
);
: t('AGENT_BOTS.EDIT.TITLE');
});
const dialogDescription = computed(() => {
if (showAccessToken.value) {
return t('AGENT_BOTS.ACCESS_TOKEN.DESCRIPTION');
}
return '';
});
const confirmButtonLabel = computed(() =>
props.type === MODAL_TYPES.CREATE
@@ -90,6 +107,13 @@ const botUrlError = computed(() =>
v$.value.botUrl.$error ? v$.value.botUrl.$errors[0]?.$message : ''
);
const showAccessTokenInput = computed(
() =>
showAccessToken.value ||
props.type === MODAL_TYPES.EDIT ||
accessToken.value
);
const resetForm = () => {
Object.assign(formState, {
botName: '',
@@ -128,6 +152,7 @@ const handleAvatarDelete = async () => {
const handleSubmit = async () => {
v$.value.$touch();
if (v$.value.$invalid) return;
if (showAccessToken.value) return;
const botData = {
name: formState.botName,
@@ -144,7 +169,7 @@ const handleSubmit = async () => {
? botData
: { id: props.selectedBot.id, data: botData };
await store.dispatch(
const response = await store.dispatch(
`agentBots/${isCreate ? 'create' : 'update'}`,
actionPayload
);
@@ -154,7 +179,21 @@ const handleSubmit = async () => {
: t('AGENT_BOTS.EDIT.API.SUCCESS_MESSAGE');
useAlert(alertKey);
dialogRef.value.close();
// Show access token after creation
if (isCreate) {
const { access_token: responseAccessToken, id } = response || {};
if (id && responseAccessToken) {
accessToken.value = responseAccessToken;
toggleAccessToken(true);
} else {
accessToken.value = '';
dialogRef.value.close();
}
} else {
dialogRef.value.close();
}
resetForm();
} catch (error) {
const errorKey = isCreate
@@ -166,17 +205,43 @@ const handleSubmit = async () => {
const initializeForm = () => {
if (props.selectedBot && Object.keys(props.selectedBot).length) {
const { name, description, outgoing_url, thumbnail, bot_config } =
props.selectedBot;
const {
name,
description,
outgoing_url: botUrl,
thumbnail,
bot_config: botConfig,
access_token: botAccessToken,
} = props.selectedBot;
formState.botName = name || '';
formState.botDescription = description || '';
formState.botUrl = outgoing_url || bot_config?.webhook_url || '';
formState.botUrl = botUrl || botConfig?.webhook_url || '';
formState.botAvatarUrl = thumbnail || '';
if (botAccessToken && props.type === MODAL_TYPES.EDIT) {
accessToken.value = botAccessToken;
}
} else {
resetForm();
}
};
const onCopyToken = async value => {
await copyTextToClipboard(value);
useAlert(t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
};
const closeModal = () => {
if (!showAccessToken.value) v$.value?.$reset();
accessToken.value = '';
toggleAccessToken(false);
};
const onClickClose = () => {
closeModal();
dialogRef.value.close();
};
watch(() => props.selectedBot, initializeForm, { immediate: true, deep: true });
defineExpose({ dialogRef });
@@ -187,48 +252,68 @@ defineExpose({ dialogRef });
ref="dialogRef"
type="edit"
:title="dialogTitle"
:description="dialogDescription"
:show-cancel-button="false"
:show-confirm-button="false"
@close="v$.$reset()"
@close="closeModal"
>
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
<div class="mb-2 flex flex-col items-start">
<span class="mb-2 text-sm font-medium text-n-slate-12">
{{ $t('AGENT_BOTS.FORM.AVATAR.LABEL') }}
</span>
<Avatar
:src="formState.botAvatarUrl"
:name="formState.botName"
:size="68"
allow-upload
@upload="handleImageUpload"
@delete="handleAvatarDelete"
<div
v-if="!showAccessToken || type === MODAL_TYPES.EDIT"
class="flex flex-col gap-4"
>
<div class="mb-2 flex flex-col items-start">
<span class="mb-2 text-sm font-medium text-n-slate-12">
{{ $t('AGENT_BOTS.FORM.AVATAR.LABEL') }}
</span>
<Avatar
:src="formState.botAvatarUrl"
:name="formState.botName"
:size="68"
allow-upload
icon-name="i-lucide-bot-message-square"
@upload="handleImageUpload"
@delete="handleAvatarDelete"
/>
</div>
<Input
id="bot-name"
v-model="formState.botName"
:label="$t('AGENT_BOTS.FORM.NAME.LABEL')"
:placeholder="$t('AGENT_BOTS.FORM.NAME.PLACEHOLDER')"
:message="botNameError"
:message-type="botNameError ? 'error' : 'info'"
@blur="v$.botName.$touch()"
/>
<TextArea
id="bot-description"
v-model="formState.botDescription"
:label="$t('AGENT_BOTS.FORM.DESCRIPTION.LABEL')"
:placeholder="$t('AGENT_BOTS.FORM.DESCRIPTION.PLACEHOLDER')"
/>
<Input
id="bot-url"
v-model="formState.botUrl"
:label="$t('AGENT_BOTS.FORM.WEBHOOK_URL.LABEL')"
:placeholder="$t('AGENT_BOTS.FORM.WEBHOOK_URL.PLACEHOLDER')"
:message="botUrlError"
:message-type="botUrlError ? 'error' : 'info'"
@blur="v$.botUrl.$touch()"
/>
</div>
<Input
v-model="formState.botName"
:label="$t('AGENT_BOTS.FORM.NAME.LABEL')"
:placeholder="$t('AGENT_BOTS.FORM.NAME.PLACEHOLDER')"
:message="botNameError"
:message-type="botNameError ? 'error' : 'info'"
@blur="v$.botName.$touch()"
/>
<TextArea
v-model="formState.botDescription"
:label="$t('AGENT_BOTS.FORM.DESCRIPTION.LABEL')"
:placeholder="$t('AGENT_BOTS.FORM.DESCRIPTION.PLACEHOLDER')"
/>
<Input
v-model="formState.botUrl"
:label="$t('AGENT_BOTS.FORM.WEBHOOK_URL.LABEL')"
:placeholder="$t('AGENT_BOTS.FORM.WEBHOOK_URL.PLACEHOLDER')"
:message="botUrlError"
:message-type="botUrlError ? 'error' : 'info'"
@blur="v$.botUrl.$touch()"
/>
<div v-if="showAccessTokenInput" class="flex flex-col gap-1">
<label
v-if="type === MODAL_TYPES.EDIT"
class="mb-0.5 text-sm font-medium text-n-slate-12"
>
{{ $t('AGENT_BOTS.ACCESS_TOKEN.TITLE') }}
</label>
<AccessToken :value="accessToken" @on-copy="onCopyToken" />
</div>
<div class="flex items-center justify-end w-full gap-2 px-0 py-2">
<NextButton
@@ -236,9 +321,10 @@ defineExpose({ dialogRef });
slate
type="reset"
:label="$t('AGENT_BOTS.FORM.CANCEL')"
@click="dialogRef.close()"
@click="onClickClose()"
/>
<NextButton
v-if="!showAccessToken"
type="submit"
data-testid="label-submit"
:label="confirmButtonLabel"
@@ -39,6 +39,7 @@ const onClick = () => {
<template #masked>
<button
class="absolute top-1.5 ltr:right-0.5 rtl:left-0.5"
type="button"
@click="toggleMasked"
>
<fluent-icon :icon="maskIcon" :size="16" />
@@ -46,7 +47,7 @@ const onClick = () => {
</template>
</woot-input>
<FormButton
type="submit"
type="button"
size="large"
icon="text-copy"
variant="outline"
+11 -2
View File
@@ -2,6 +2,8 @@ import types from '../mutation-types';
import authAPI from '../../api/auth';
import { setUser, clearCookiesOnLogout } from '../utils/api';
import SessionStorage from 'shared/helpers/sessionStorage';
import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage';
const initialState = {
currentUser: {
@@ -145,8 +147,15 @@ export const actions = {
updateUISettings: async ({ commit }, params) => {
try {
commit(types.SET_CURRENT_USER_UI_SETTINGS, params);
const response = await authAPI.updateUISettings(params);
commit(types.SET_CURRENT_USER, response.data);
const isImpersonating = SessionStorage.get(
SESSION_STORAGE_KEYS.IMPERSONATION_USER
);
if (!isImpersonating) {
const response = await authAPI.updateUISettings(params);
commit(types.SET_CURRENT_USER, response.data);
}
} catch (error) {
// Ignore error
}
@@ -2,7 +2,9 @@ import fromUnixTime from 'date-fns/fromUnixTime';
import differenceInDays from 'date-fns/differenceInDays';
import Cookies from 'js-cookie';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
import SessionStorage from 'shared/helpers/sessionStorage';
import { emitter } from 'shared/helpers/mitt';
import {
ANALYTICS_IDENTITY,
@@ -44,6 +46,10 @@ export const clearLocalStorageOnLogout = () => {
LocalStorage.remove(LOCAL_STORAGE_KEYS.DRAFT_MESSAGES);
};
export const clearSessionStorageOnLogout = () => {
SessionStorage.remove(SESSION_STORAGE_KEYS.IMPERSONATION_USER);
};
export const deleteIndexedDBOnLogout = async () => {
let dbs = [];
try {
@@ -75,6 +81,7 @@ export const clearCookiesOnLogout = () => {
emitter.emit(ANALYTICS_RESET);
clearBrowserSessionCookies();
clearLocalStorageOnLogout();
clearSessionStorageOnLogout();
const globalConfig = window.globalConfig || {};
const logoutRedirectLink = globalConfig.LOGOUT_REDIRECT_LINK || '/';
window.location = logoutRedirectLink;
@@ -0,0 +1,26 @@
export default {
clearAll() {
window.sessionStorage.clear();
},
get(key) {
try {
const value = window.sessionStorage.getItem(key);
return value ? JSON.parse(value) : null;
} catch (error) {
return window.sessionStorage.getItem(key);
}
},
set(key, value) {
if (typeof value === 'object') {
window.sessionStorage.setItem(key, JSON.stringify(value));
} else {
window.sessionStorage.setItem(key, value);
}
},
remove(key) {
window.sessionStorage.removeItem(key);
},
};
@@ -0,0 +1,137 @@
import SessionStorage from '../sessionStorage';
// Mocking sessionStorage
const sessionStorageMock = (() => {
let store = {};
return {
getItem: key => store[key] || null,
setItem: (key, value) => {
store[key] = String(value);
},
removeItem: key => delete store[key],
clear: () => {
store = {};
},
};
})();
Object.defineProperty(window, 'sessionStorage', {
value: sessionStorageMock,
});
describe('SessionStorage utility', () => {
beforeEach(() => {
sessionStorage.clear();
});
describe('clearAll method', () => {
it('should clear all items from sessionStorage', () => {
sessionStorage.setItem('testKey1', 'testValue1');
sessionStorage.setItem('testKey2', 'testValue2');
SessionStorage.clearAll();
expect(sessionStorage.getItem('testKey1')).toBeNull();
expect(sessionStorage.getItem('testKey2')).toBeNull();
});
});
describe('get method', () => {
it('should retrieve and parse JSON values correctly', () => {
const testObject = { a: 1, b: 'test' };
sessionStorage.setItem('testKey', JSON.stringify(testObject));
expect(SessionStorage.get('testKey')).toEqual(testObject);
});
it('should return null for non-existent keys', () => {
expect(SessionStorage.get('nonExistentKey')).toBeNull();
});
it('should handle non-JSON values by returning the raw value', () => {
sessionStorage.setItem('testKey', 'plain string value');
expect(SessionStorage.get('testKey')).toBe('plain string value');
});
it('should handle malformed JSON gracefully', () => {
sessionStorage.setItem('testKey', '{malformed:json}');
expect(SessionStorage.get('testKey')).toBe('{malformed:json}');
});
});
describe('set method', () => {
it('should store object values as JSON strings', () => {
const testObject = { a: 1, b: 'test' };
SessionStorage.set('testKey', testObject);
expect(sessionStorage.getItem('testKey')).toBe(
JSON.stringify(testObject)
);
});
it('should store primitive values directly', () => {
SessionStorage.set('stringKey', 'test string');
expect(sessionStorage.getItem('stringKey')).toBe('test string');
SessionStorage.set('numberKey', 42);
expect(sessionStorage.getItem('numberKey')).toBe('42');
SessionStorage.set('booleanKey', true);
expect(sessionStorage.getItem('booleanKey')).toBe('true');
});
it('should handle null values', () => {
SessionStorage.set('nullKey', null);
expect(sessionStorage.getItem('nullKey')).toBe('null');
expect(SessionStorage.get('nullKey')).toBeNull();
});
it('should handle undefined values', () => {
SessionStorage.set('undefinedKey', undefined);
expect(sessionStorage.getItem('undefinedKey')).toBe('undefined');
});
});
describe('remove method', () => {
it('should remove an item from sessionStorage', () => {
SessionStorage.set('testKey', 'testValue');
expect(SessionStorage.get('testKey')).toBe('testValue');
SessionStorage.remove('testKey');
expect(SessionStorage.get('testKey')).toBeNull();
});
it('should do nothing when removing a non-existent key', () => {
expect(() => {
SessionStorage.remove('nonExistentKey');
}).not.toThrow();
});
});
describe('Integration of methods', () => {
it('should set, get, and remove values correctly', () => {
SessionStorage.set('testKey', { value: 'test' });
expect(SessionStorage.get('testKey')).toEqual({ value: 'test' });
SessionStorage.remove('testKey');
expect(SessionStorage.get('testKey')).toBeNull();
});
it('should correctly handle impersonation flag (common use case)', () => {
SessionStorage.set('impersonationUser', true);
expect(SessionStorage.get('impersonationUser')).toBe(true);
expect(sessionStorage.getItem('impersonationUser')).toBe('true');
SessionStorage.remove('impersonationUser');
expect(SessionStorage.get('impersonationUser')).toBeNull();
});
});
});
+13 -1
View File
@@ -6,7 +6,8 @@ import { parseBoolean } from '@chatwoot/utils';
import { useAlert } from 'dashboard/composables';
import { required, email } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage';
import SessionStorage from 'shared/helpers/sessionStorage';
// mixins
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
@@ -21,6 +22,8 @@ const ERROR_MESSAGES = {
'business-account-only': 'LOGIN.OAUTH.BUSINESS_ACCOUNTS_ONLY',
};
const IMPERSONATION_URL_SEARCH_KEY = 'impersonation';
export default {
components: {
FormInput,
@@ -112,6 +115,14 @@ export default {
this.loginApi.message = message;
useAlert(this.loginApi.message);
},
handleImpersonation() {
// Detects impersonation mode via URL and sets a session flag to prevent user settings changes during impersonation.
const urlParams = new URLSearchParams(window.location.search);
const impersonation = urlParams.get(IMPERSONATION_URL_SEARCH_KEY);
if (impersonation) {
SessionStorage.set(SESSION_STORAGE_KEYS.IMPERSONATION_USER, true);
}
},
submitLogin() {
this.loginApi.hasErrored = false;
this.loginApi.showLoading = true;
@@ -128,6 +139,7 @@ export default {
login(credentials)
.then(() => {
this.handleImpersonation();
this.showAlertMessage(this.$t('LOGIN.API.SUCCESS_MESSAGE'));
})
.catch(response => {