Compare commits

...
Author SHA1 Message Date
Vishnu Narayanan 6bfacd5381 chore: update omniauth callback controller spec 2025-04-11 18:13:58 +05:30
PranavandGitHub 50344a282b fix: Return new Array instead of freezed object (#11283)
Users who have not changed the order of the sidebar items were not able
to change it as the object returned was created using Object.freeze

To reproduce: 

- Create a new user account, open a conversation, try changing the order
of the sidebar items.
2025-04-11 13:02:16 +05:30
Muhsin KelothandGitHub 65cd0717e6 fix: Handle Instagram echo events (#11275)
This PR fixes the issue with message creation when someone sends
messages from the Instagram app instead of the Chatwoot dashboard.
2025-04-10 12:58:38 +05:30
73dcf539ed feat: allow role based filtering on the frontend (#11246)
This pull request introduces frontend role filtering to allStatusChat
getter. The key changes include the addition of a new helper function to
get the user's role, updates to the conversation filtering logic to
incorporate role and permissions, and the addition of unit tests for the
new filtering logic.

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-04-09 11:46:20 -07:00
Sivin VargheseandGitHub 35d9dc925f chore: Remove sorting by phone_number from contact list (#11271) 2025-04-09 18:41:18 +05:30
Sivin VargheseandGitHub 2d14f27d5b chore: Update date range picker with new theme colors (#11267) 2025-04-09 16:03:53 +05:30
Sivin VargheseandGitHub 70a65e2c34 fix: Prevent mentions menu from triggering on reply mode change (#11264)
# Pull Request Template

## Description

**Issue:** When typing "@" in reply mode and then switching to private
note mode, the user mentions menu remains visible and cannot be
dismissed.

**Cause:** The Prose Mirror suggestion plugin for "@" was active in both
reply modes (normal and private). When triggered in normal reply mode
and then switching to private note mode, the menu would remain open and
couldn't be dismissed.

**Solution**
```
createSuggestionPlugin({
  trigger: '@',
  showMenu: showUserMentions,
  searchTerm: mentionSearchKey,
  isAllowed: () => props.isPrivate, // Only allow @ mentions in private note mode
}),
```
1. By setting `isAllowed: () => props.isPrivate`, the @ mention trigger
will only activate when in private note mode
2. In normal reply mode, the plugin won't activate at all since
`isAllowed` returns false
3. This prevents the menu from being triggered in the wrong context and
fix the scenario where the menu gets stuck.

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

### Loom video

**Before**

https://www.loom.com/share/5333f0c6498d4a9ea4d220b1de1b608c?sid=1425f24f-2c6f-4ff5-aab3-23c3203d2e05

**After**

https://www.loom.com/share/9f183c76d6a94b618a7c2aaed280b780?sid=19ef08b8-2b70-434b-ad5a-267410212e11

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2025-04-09 15:25:46 +05:30
22 changed files with 462 additions and 75 deletions
@@ -94,11 +94,10 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
def build_message
# Duplicate webhook events may be sent for the same message
# when a user is connected to the Instagram account through both Messenger and Instagram login.
# There is chance for echo events to be sent for the same message.
# Therefore, we need to check if the message already exists before creating it.
return if message_already_exists?
return if @outgoing_echo
return if message_content.blank? && all_unsupported_files?
@message = conversation.messages.create!(message_params)
@@ -29,6 +29,19 @@
--iris-11: 87 83 198;
--iris-12: 39 41 98;
--blue-1: 251 253 255;
--blue-2: 245 249 255;
--blue-3: 233 243 255;
--blue-4: 218 236 255;
--blue-5: 201 226 255;
--blue-6: 181 213 255;
--blue-7: 155 195 252;
--blue-8: 117 171 247;
--blue-9: 39 129 246;
--blue-10: 16 115 233;
--blue-11: 8 109 224;
--blue-12: 11 50 101;
--ruby-1: 255 252 253;
--ruby-2: 255 247 248;
--ruby-3: 254 234 237;
@@ -131,6 +144,19 @@
--iris-11: 158 177 255;
--iris-12: 224 223 254;
--blue-1: 10 17 28;
--blue-2: 15 24 38;
--blue-3: 15 39 72;
--blue-4: 10 49 99;
--blue-5: 18 61 117;
--blue-6: 29 84 134;
--blue-7: 40 89 156;
--blue-8: 48 106 186;
--blue-9: 39 129 246;
--blue-10: 21 116 231;
--blue-11: 126 182 255;
--blue-12: 205 227 255;
--ruby-1: 25 17 19;
--ruby-2: 30 21 23;
--ruby-3: 58 20 30;
@@ -51,6 +51,7 @@ defineExpose({ dialogRef, contactsFormRef, onSuccess });
<Button
:label="t('DIALOG.BUTTONS.CANCEL')"
variant="link"
type="reset"
class="h-10 hover:!no-underline hover:text-n-brand"
@click="closeDialog"
/>
@@ -31,10 +31,6 @@ const sortMenus = [
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.EMAIL'),
value: 'email',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.PHONE_NUMBER'),
value: 'phone_number',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.COMPANY'),
value: 'company_name',
@@ -218,14 +218,14 @@ const emitDateRange = () => {
/>
<div
v-if="showDatePicker"
class="flex absolute top-9 ltr:left-0 rtl:right-0 z-30 shadow-md select-none w-[880px] h-[490px] rounded-2xl border border-slate-50 dark:border-slate-800 bg-white dark:bg-slate-800"
class="flex absolute top-9 ltr:left-0 rtl:right-0 z-30 shadow-md select-none w-[880px] h-[490px] rounded-2xl bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container"
>
<CalendarDateRange
:selected-range="selectedRange"
@set-range="setDateRange"
/>
<div
class="flex flex-col w-[680px] ltr:border-l rtl:border-r border-slate-50 dark:border-slate-700/50"
class="flex flex-col w-[680px] ltr:border-l rtl:border-r border-n-strong"
>
<div class="flex justify-around h-fit">
<!-- Calendars for Start and End Dates -->
@@ -251,12 +251,12 @@ const emitDateRange = () => {
@validate="updateManualInput($event, calendar)"
@error="handleManualInputError($event)"
/>
<div class="py-5 border-b border-slate-50 dark:border-slate-700/50">
<div class="py-5 border-b border-n-strong">
<div
class="flex flex-col items-center gap-2 px-5 min-w-[340px] max-h-[352px]"
:class="
calendar === START_CALENDAR &&
'ltr:border-r rtl:border-l border-slate-50 dark:border-slate-700/50'
'ltr:border-r rtl:border-l border-n-strong'
"
>
<CalendarYear
@@ -1,6 +1,8 @@
<script setup>
import { CALENDAR_PERIODS } from '../helpers/DatePickerHelper';
import NextButton from 'dashboard/components-next/button/Button.vue';
defineProps({
calendarType: {
type: String,
@@ -38,42 +40,38 @@ const onClickSetView = (type, mode) => {
<template>
<div class="flex items-start justify-between w-full h-9">
<button
class="p-1 rounded-lg hover:bg-slate-75 dark:hover:bg-slate-700/50 rtl:rotate-180"
<NextButton
slate
ghost
xs
icon="i-lucide-chevron-left"
class="rtl:rotate-180"
@click="onClickPrev(calendarType)"
>
<fluent-icon
icon="chevron-left"
size="14"
class="text-slate-900 dark:text-slate-50"
/>
</button>
/>
<div class="flex items-center gap-1">
<button
v-if="firstButtonLabel"
class="p-0 text-sm font-medium text-center text-slate-800 dark:text-slate-50 hover:text-woot-600 dark:hover:text-woot-600"
class="p-0 text-sm font-medium text-center text-n-slate-12 hover:text-n-brand"
@click="onClickSetView(calendarType, viewMode)"
>
{{ firstButtonLabel }}
</button>
<button
v-if="buttonLabel"
class="p-0 text-sm font-medium text-center text-slate-800 dark:text-slate-50"
:class="{ 'hover:text-woot-600 dark:hover:text-woot-600': viewMode }"
class="p-0 text-sm font-medium text-center text-n-slate-12"
:class="{ 'hover:text-n-brand': viewMode }"
@click="onClickSetView(calendarType, YEAR)"
>
{{ buttonLabel }}
</button>
</div>
<button
class="p-1 rounded-lg hover:bg-slate-75 dark:hover:bg-slate-700/50 rtl:rotate-180"
<NextButton
slate
ghost
xs
icon="i-lucide-chevron-right"
class="rtl:rotate-180"
@click="onClickNext(calendarType)"
>
<fluent-icon
icon="chevron-right"
size="14"
class="text-slate-900 dark:text-slate-50"
/>
</button>
/>
</div>
</template>
@@ -65,7 +65,7 @@ const validateDate = () => {
<input
v-model="localDateValue"
type="text"
class="reset-base border bg-slate-25 dark:bg-slate-900 ring-offset-ash-900 border-slate-50 dark:border-slate-700/50 w-full disabled:text-slate-200 dark:disabled:text-slate-700 disabled:cursor-not-allowed text-slate-800 dark:text-slate-50 px-1.5 py-1 text-sm rounded-xl h-10"
class="!text-sm !mb-0 disabled:!outline-n-strong"
:placeholder="dateFormat"
:disabled="isDisabled"
@keypress.enter="validateDate"
@@ -18,7 +18,7 @@ const setDateRange = range => {
<template>
<div class="w-[200px] flex flex-col items-start">
<h4
class="w-full px-5 py-4 text-sm font-medium capitalize text-start text-slate-600 dark:text-slate-200"
class="w-full px-5 py-4 text-sm font-medium capitalize text-start text-n-slate-12"
>
{{ $t('DATE_PICKER.DATE_RANGE_OPTIONS.TITLE') }}
</h4>
@@ -26,11 +26,11 @@ const setDateRange = range => {
<button
v-for="range in dateRanges"
:key="range.label"
class="w-full px-5 py-3 text-sm font-medium truncate border-none rounded-none text-start hover:bg-slate-50 dark:hover:bg-slate-700"
class="w-full px-5 py-3 text-sm font-medium truncate border-none rounded-none text-start hover:bg-n-alpha-2 dark:hover:bg-n-solid-3"
:class="
range.value === selectedRange
? 'text-slate-800 dark:text-slate-50 bg-slate-50 dark:bg-slate-700'
: 'text-slate-600 dark:text-slate-200'
? 'text-n-slate-12 bg-n-alpha-1 dark:bg-n-solid-active'
: 'text-n-slate-12'
"
@click="setDateRange(range)"
>
@@ -1,4 +1,6 @@
<script setup>
import NextButton from 'dashboard/components-next/button/Button.vue';
const emit = defineEmits(['clear', 'change']);
const onClickClear = () => {
@@ -11,18 +13,19 @@ const onClickApply = () => {
</script>
<template>
<div class="h-[56px] flex justify-between px-5 py-3 items-center">
<button
class="p-1.5 rounded-lg w-fit text-sm font-medium text-slate-600 dark:text-slate-200 hover:text-slate-800 dark:hover:text-slate-100"
<div class="h-[56px] flex justify-between gap-2 px-2 py-3 items-center">
<NextButton
slate
ghost
sm
:label="$t('DATE_PICKER.CLEAR_BUTTON')"
@click="onClickClear"
>
{{ $t('DATE_PICKER.CLEAR_BUTTON') }}
</button>
<button
class="p-1.5 rounded-lg w-fit text-sm font-medium text-woot-500 dark:text-woot-300 hover:text-woot-700 dark:hover:text-woot-500"
/>
<NextButton
sm
ghost
:label="$t('DATE_PICKER.APPLY_BUTTON')"
@click="onClickApply"
>
{{ $t('DATE_PICKER.APPLY_BUTTON') }}
</button>
/>
</div>
</template>
@@ -71,10 +71,12 @@ const selectMonth = index => {
<button
v-for="(month, index) in months"
:key="index"
class="p-2 text-sm font-medium text-center text-slate-800 dark:text-slate-50 w-[92px] h-10 rounded-lg py-2.5 px-2 hover:bg-slate-75 dark:hover:bg-slate-700"
class="p-2 text-sm font-medium text-center text-n-slate-12 w-[92px] h-10 rounded-lg py-2.5 px-2"
:class="{
'bg-woot-600 dark:bg-woot-600 text-white dark:text-white hover:bg-woot-500 dark:bg-woot-700':
'bg-n-brand text-white hover:bg-n-blue-10':
index === activeMonthIndex,
'hover:bg-n-alpha-2 dark:hover:bg-n-solid-3':
index !== activeMonthIndex,
}"
@click="selectMonth(index)"
>
@@ -107,17 +107,16 @@ const isNextDayInRange = day => {
};
const dayClasses = day => ({
'text-slate-500 dark:text-slate-400 pointer-events-none':
!isInCurrentMonth(day),
'text-slate-800 dark:text-slate-50 hover:text-slate-800 dark:hover:text-white hover:bg-woot-100 dark:hover:bg-woot-700':
'text-n-slate-10 pointer-events-none': !isInCurrentMonth(day),
'text-n-slate-12 hover:text-n-slate-12 hover:bg-n-blue-6 dark:hover:bg-n-blue-7':
isInCurrentMonth(day),
'bg-woot-600 dark:bg-woot-600 text-white dark:text-white':
'bg-n-brand text-white':
isSelectedStartOrEndDate(day) && isInCurrentMonth(day),
'bg-woot-50 dark:bg-woot-800':
'bg-n-blue-4 dark:bg-n-blue-5':
(isInRange(day) || isHoveringInRange(day)) &&
!isSelectedStartOrEndDate(day) &&
isInCurrentMonth(day),
'outline outline-1 outline-woot-200 -outline-offset-1 dark:outline-woot-700 text-woot-600 dark:text-woot-400':
'outline outline-1 outline-n-blue-8 -outline-offset-1 !text-n-blue-text':
isToday(props.currentDate, day) && !isSelectedStartOrEndDate(day),
});
</script>
@@ -164,7 +163,7 @@ const dayClasses = day => ({
!isLastDayOfMonth(day) &&
isInCurrentMonth(day)
"
class="absolute bottom-0 w-6 h-8 ltr:-right-4 rtl:-left-4 bg-woot-50 dark:bg-woot-800 -z-10"
class="absolute bottom-0 w-6 h-8 ltr:-right-4 rtl:-left-4 bg-n-blue-4 dark:bg-n-blue-5 -z-10"
/>
</div>
</div>
@@ -72,10 +72,10 @@ const selectYear = year => {
<button
v-for="year in years"
:key="year"
class="p-2 text-sm font-medium text-center text-slate-800 dark:text-slate-50 w-[144px] h-10 rounded-lg py-2.5 px-2 hover:bg-slate-75 dark:hover:bg-slate-700"
class="p-2 text-sm font-medium text-center text-n-slate-12 w-[144px] h-10 rounded-lg py-2.5 px-2"
:class="{
'bg-woot-600 dark:bg-woot-600 text-white dark:text-white hover:bg-woot-500 dark:hover:bg-woot-700':
year === activeYear,
'bg-n-brand text-white hover:bg-n-blue-10': year === activeYear,
'hover:bg-n-alpha-2 dark:hover:bg-n-solid-3': year !== activeYear,
}"
@click="selectYear(year)"
>
@@ -48,7 +48,7 @@ const openDatePicker = () => {
<template>
<button
class="inline-flex relative items-center rounded-lg gap-2 py-1.5 px-3 h-8 bg-slate-50 dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800 active:bg-slate-75 dark:active:bg-slate-800"
class="inline-flex relative items-center rounded-lg gap-2 py-1.5 px-3 h-8 bg-n-alpha-2 hover:bg-n-alpha-1 active:bg-n-alpha-1"
@click="openDatePicker"
>
<fluent-icon
@@ -220,6 +220,7 @@ const plugins = computed(() => {
trigger: '@',
showMenu: showUserMentions,
searchTerm: mentionSearchKey,
isAllowed: () => props.isPrivate,
}),
createSuggestionPlugin({
trigger: '/',
@@ -69,10 +69,6 @@ const onAgentSelect = index => {
v-if="items.length"
ref="tagAgentsRef"
class="vertical dropdown menu mention--box bg-n-solid-1 p-1 rounded-xl text-sm overflow-auto absolute w-full z-20 shadow-md left-0 leading-[1.2] bottom-full max-h-[12.5rem] border border-solid border-n-strong"
:class="{
'border-b-[0.5rem] border-solid border-white dark:!border-slate-700':
items.length <= 4,
}"
>
<li
v-for="(agent, index) in items"
@@ -36,7 +36,7 @@ const useConversationSidebarItemsOrder = uiSettings => {
const { conversation_sidebar_items_order: itemsOrder } = uiSettings.value;
// If the sidebar order is not set, use the default order.
if (!itemsOrder) {
return DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER;
return [...DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER];
}
// Create a copy of itemsOrder to avoid mutating the original store object.
const itemsOrderCopy = [...itemsOrder];
@@ -16,6 +16,15 @@ export const getUserPermissions = (user, accountId) => {
return currentAccount.permissions || [];
};
export const getUserRole = (user, accountId) => {
const currentAccount = getCurrentAccount(user, accountId) || {};
if (currentAccount.custom_role_id) {
return 'custom_role';
}
return currentAccount.role || 'agent';
};
const isPermissionsPresentInRoute = route =>
route.meta && route.meta.permissions;
@@ -1,7 +1,11 @@
import { MESSAGE_TYPE } from 'shared/constants/messages';
import { applyPageFilters, sortComparator } from './helpers';
import { applyPageFilters, applyRoleFilter, sortComparator } from './helpers';
import filterQueryGenerator from 'dashboard/helper/filterQueryGenerator';
import { matchesFilters } from './helpers/filterHelpers';
import {
getUserPermissions,
getUserRole,
} from '../../../helper/permissionsHelper';
import camelcaseKeys from 'camelcase-keys';
export const getSelectedChatConversation = ({
@@ -77,10 +81,24 @@ const getters = {
return isUnAssigned && shouldFilter;
});
},
getAllStatusChats: _state => activeFilters => {
getAllStatusChats: (_state, _, __, rootGetters) => activeFilters => {
const currentUser = rootGetters.getCurrentUser;
const currentUserId = rootGetters.getCurrentUser.id;
const currentAccountId = rootGetters.getCurrentAccountId;
const permissions = getUserPermissions(currentUser, currentAccountId);
const userRole = getUserRole(currentUser, currentAccountId);
return _state.allConversations.filter(conversation => {
const shouldFilter = applyPageFilters(conversation, activeFilters);
return shouldFilter;
const allowedForRole = applyRoleFilter(
conversation,
userRole,
permissions,
currentUserId
);
return shouldFilter && allowedForRole;
});
},
getChatListLoadingStatus: ({ listLoadingStatus }) => listLoadingStatus,
@@ -62,6 +62,51 @@ export const applyPageFilters = (conversation, filters) => {
return shouldFilter;
};
/**
* Filters conversations based on user role and permissions
*
* @param {Object} conversation - The conversation object to check permissions for
* @param {string} role - The user's role (administrator, agent, etc.)
* @param {Array<string>} permissions - List of permission strings the user has
* @param {number|string} currentUserId - The ID of the current user
* @returns {boolean} - Whether the user has permissions to access this conversation
*/
export const applyRoleFilter = (
conversation,
role,
permissions,
currentUserId
) => {
// the role === "agent" check is typically not correct on it's own
// the backend handles this by checking the custom_role_id at the user model
// here however, the `getUserRole` returns "custom_role" if the id is present,
// so we can check the role === "agent" directly
if (['administrator', 'agent'].includes(role)) {
return true;
}
// Check for full conversation management permission
if (permissions.includes('conversation_manage')) {
return true;
}
const conversationAssignee = conversation.meta.assignee;
const isUnassigned = !conversationAssignee;
const isAssignedToUser = conversationAssignee?.id === currentUserId;
// Check unassigned management permission
if (permissions.includes('conversation_unassigned_manage')) {
return isUnassigned || isAssignedToUser;
}
// Check participating conversation management permission
if (permissions.includes('conversation_participating_manage')) {
return isAssignedToUser;
}
return false;
};
const SORT_OPTIONS = {
last_activity_at_asc: ['sortOnLastActivityAt', 'asc'],
last_activity_at_desc: ['sortOnLastActivityAt', 'desc'],
@@ -0,0 +1,276 @@
import { describe, it, expect } from 'vitest';
import { applyRoleFilter } from '../helpers';
describe('Conversation Helpers', () => {
describe('#applyRoleFilter', () => {
// Test data for conversations
const conversationWithAssignee = {
meta: {
assignee: {
id: 1,
},
},
};
const conversationWithDifferentAssignee = {
meta: {
assignee: {
id: 2,
},
},
};
const conversationWithoutAssignee = {
meta: {
assignee: null,
},
};
// Test for administrator role
it('always returns true for administrator role regardless of permissions', () => {
const role = 'administrator';
const permissions = [];
const currentUserId = 1;
expect(
applyRoleFilter(
conversationWithAssignee,
role,
permissions,
currentUserId
)
).toBe(true);
expect(
applyRoleFilter(
conversationWithDifferentAssignee,
role,
permissions,
currentUserId
)
).toBe(true);
expect(
applyRoleFilter(
conversationWithoutAssignee,
role,
permissions,
currentUserId
)
).toBe(true);
});
// Test for agent role
it('always returns true for agent role regardless of permissions', () => {
const role = 'agent';
const permissions = [];
const currentUserId = 1;
expect(
applyRoleFilter(
conversationWithAssignee,
role,
permissions,
currentUserId
)
).toBe(true);
expect(
applyRoleFilter(
conversationWithDifferentAssignee,
role,
permissions,
currentUserId
)
).toBe(true);
expect(
applyRoleFilter(
conversationWithoutAssignee,
role,
permissions,
currentUserId
)
).toBe(true);
});
// Test for custom role with 'conversation_manage' permission
it('returns true for any user with conversation_manage permission', () => {
const role = 'custom_role';
const permissions = ['conversation_manage'];
const currentUserId = 1;
expect(
applyRoleFilter(
conversationWithAssignee,
role,
permissions,
currentUserId
)
).toBe(true);
expect(
applyRoleFilter(
conversationWithDifferentAssignee,
role,
permissions,
currentUserId
)
).toBe(true);
expect(
applyRoleFilter(
conversationWithoutAssignee,
role,
permissions,
currentUserId
)
).toBe(true);
});
// Test for custom role with 'conversation_unassigned_manage' permission
describe('with conversation_unassigned_manage permission', () => {
const role = 'custom_role';
const permissions = ['conversation_unassigned_manage'];
const currentUserId = 1;
it('returns true for conversations assigned to the user', () => {
expect(
applyRoleFilter(
conversationWithAssignee,
role,
permissions,
currentUserId
)
).toBe(true);
});
it('returns true for unassigned conversations', () => {
expect(
applyRoleFilter(
conversationWithoutAssignee,
role,
permissions,
currentUserId
)
).toBe(true);
});
it('returns false for conversations assigned to other users', () => {
expect(
applyRoleFilter(
conversationWithDifferentAssignee,
role,
permissions,
currentUserId
)
).toBe(false);
});
});
// Test for custom role with 'conversation_participating_manage' permission
describe('with conversation_participating_manage permission', () => {
const role = 'custom_role';
const permissions = ['conversation_participating_manage'];
const currentUserId = 1;
it('returns true for conversations assigned to the user', () => {
expect(
applyRoleFilter(
conversationWithAssignee,
role,
permissions,
currentUserId
)
).toBe(true);
});
it('returns false for unassigned conversations', () => {
expect(
applyRoleFilter(
conversationWithoutAssignee,
role,
permissions,
currentUserId
)
).toBe(false);
});
it('returns false for conversations assigned to other users', () => {
expect(
applyRoleFilter(
conversationWithDifferentAssignee,
role,
permissions,
currentUserId
)
).toBe(false);
});
});
// Test for user with no relevant permissions
it('returns false for custom role without any relevant permissions', () => {
const role = 'custom_role';
const permissions = ['some_other_permission'];
const currentUserId = 1;
expect(
applyRoleFilter(
conversationWithAssignee,
role,
permissions,
currentUserId
)
).toBe(false);
expect(
applyRoleFilter(
conversationWithDifferentAssignee,
role,
permissions,
currentUserId
)
).toBe(false);
expect(
applyRoleFilter(
conversationWithoutAssignee,
role,
permissions,
currentUserId
)
).toBe(false);
});
// Test edge cases for meta.assignee
describe('handles edge cases with meta.assignee', () => {
const role = 'custom_role';
const permissions = ['conversation_unassigned_manage'];
const currentUserId = 1;
it('treats undefined assignee as unassigned', () => {
const conversationWithUndefinedAssignee = {
meta: {
assignee: undefined,
},
};
expect(
applyRoleFilter(
conversationWithUndefinedAssignee,
role,
permissions,
currentUserId
)
).toBe(true);
});
it('handles empty meta object', () => {
const conversationWithEmptyMeta = {
meta: {},
};
expect(
applyRoleFilter(
conversationWithEmptyMeta,
role,
permissions,
currentUserId
)
).toBe(true);
});
});
});
});
@@ -44,15 +44,18 @@ RSpec.describe 'DeviseOverrides::OmniauthCallbacksController', type: :request do
it 'blocks personal accounts signup' do
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true' do
set_omniauth_config('personal@gmail.com')
get '/omniauth/google_oauth2/callback'
# Test different case variations of Gmail
['personal@gmail.com', 'personal@Gmail.com', 'personal@GMAIL.com', 'personal@Gmail.COM'].each do |email|
set_omniauth_config(email)
get '/omniauth/google_oauth2/callback'
# expect a 302 redirect to auth/google_oauth2/callback
expect(response).to redirect_to('http://www.example.com/auth/google_oauth2/callback')
follow_redirect!
# expect a 302 redirect to auth/google_oauth2/callback
expect(response).to redirect_to('http://www.example.com/auth/google_oauth2/callback')
follow_redirect!
# expect a 302 redirect to app/login with error disallowing personal accounts
expect(response).to redirect_to(%r{/app/login\?error=business-account-only$})
# expect a 302 redirect to app/login with error disallowing personal accounts
expect(response).to redirect_to(%r{/app/login\?error=business-account-only$})
end
end
end
+15
View File
@@ -315,6 +315,21 @@ export const colors = {
12: 'rgb(var(--iris-12) / <alpha-value>)',
},
blue: {
1: 'rgb(var(--blue-1) / <alpha-value>)',
2: 'rgb(var(--blue-2) / <alpha-value>)',
3: 'rgb(var(--blue-3) / <alpha-value>)',
4: 'rgb(var(--blue-4) / <alpha-value>)',
5: 'rgb(var(--blue-5) / <alpha-value>)',
6: 'rgb(var(--blue-6) / <alpha-value>)',
7: 'rgb(var(--blue-7) / <alpha-value>)',
8: 'rgb(var(--blue-8) / <alpha-value>)',
9: 'rgb(var(--blue-9) / <alpha-value>)',
10: 'rgb(var(--blue-10) / <alpha-value>)',
11: 'rgb(var(--blue-11) / <alpha-value>)',
12: 'rgb(var(--blue-12) / <alpha-value>)',
},
ruby: {
1: 'rgb(var(--ruby-1) / <alpha-value>)',
2: 'rgb(var(--ruby-2) / <alpha-value>)',