Merge branch 'develop' into chore/rename_private

This commit is contained in:
Sojan Jose 2021-04-26 22:49:11 +05:30 committed by GitHub
commit 49ca3b09ca
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
44 changed files with 678 additions and 100 deletions

8
SECURITY.md Normal file
View file

@ -0,0 +1,8 @@
# Security Policy
## Reporting a Vulnerability
We use [huntr.dev](https://huntr.dev/) for security issues that affect our project. If you believe you have found a vulnerability, please disclose it via this [form](https://huntr.dev/bounties/disclose).
This will enable us to review the vulnerability, fix it promptly, and reward you for your efforts.
If you have any questions about the process, feel free to reach out to hello@chatwoot.com.

View file

@ -43,6 +43,8 @@ class ContactBuilder
contact ||= account.contacts.find_by(email: contact_attributes[:email]) if contact_attributes[:email].present?
contact ||= account.contacts.find_by(phone_number: contact_attributes[:phone_number]) if contact_attributes[:phone_number].present?
contact
end

View file

@ -7,6 +7,10 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
@inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] }))
end
def assignable_agents
@assignable_agents = (Current.account.users.where(id: @inbox.members.select(:user_id)) + Current.account.administrators).uniq
end
def create
ActiveRecord::Base.transaction do
channel = create_channel

View file

@ -0,0 +1,44 @@
class SuperAdmin::PlatformAppsController < SuperAdmin::ApplicationController
# Overwrite any of the RESTful controller actions to implement custom behavior
# For example, you may want to send an email after a foo is updated.
#
# def update
# super
# send_foo_updated_email(requested_resource)
# end
# Override this method to specify custom lookup behavior.
# This will be used to set the resource for the `show`, `edit`, and `update`
# actions.
#
# def find_resource(param)
# Foo.find_by!(slug: param)
# end
# The result of this lookup will be available as `requested_resource`
# Override this if you have certain roles that require a subset
# this will be used to set the records shown on the `index` action.
#
# def scoped_resource
# if current_user.super_admin?
# resource_class
# else
# resource_class.with_less_stuff
# end
# end
# Override `resource_params` if you want to transform the submitted
# data before it's persisted. For example, the following would turn all
# empty values into nil values. It uses other APIs such as `resource_class`
# and `dashboard`:
#
# def resource_params
# params.require(resource_class.model_name.param_key).
# permit(dashboard.permitted_attributes).
# transform_values { |value| value == "" ? nil : value }
# end
# See https://administrate-prototype.herokuapp.com/customizing_controller_actions
# for more information
end

View file

@ -57,8 +57,8 @@ class AccessTokenDashboard < Administrate::BaseDashboard
# }.freeze
COLLECTION_FILTERS = {
user: ->(resources) { resources.where(owner_type: 'User') },
super_admin: ->(resources) { resources.where(owner_type: 'SuperAdmin') },
agent_bot: ->(resources) { resources.where(owner_type: 'AgentBot') }
agent_bot: ->(resources) { resources.where(owner_type: 'AgentBot') },
platform_app: ->(resources) { resources.where(owner_type: 'PlatformApp') }
}.freeze
# Overwrite this method to customize how access tokens are displayed

View file

@ -0,0 +1,62 @@
require 'administrate/base_dashboard'
class PlatformAppDashboard < Administrate::BaseDashboard
# ATTRIBUTE_TYPES
# a hash that describes the type of each of the model's fields.
#
# Each different type represents an Administrate::Field object,
# which determines how the attribute is displayed
# on pages throughout the dashboard.
ATTRIBUTE_TYPES = {
access_token: Field::HasOne,
id: Field::Number,
name: Field::String,
created_at: Field::DateTime,
updated_at: Field::DateTime
}.freeze
# COLLECTION_ATTRIBUTES
# an array of attributes that will be displayed on the model's index page.
#
# By default, it's limited to four items to reduce clutter on index pages.
# Feel free to add, remove, or rearrange items.
COLLECTION_ATTRIBUTES = %i[
id
name
].freeze
# SHOW_PAGE_ATTRIBUTES
# an array of attributes that will be displayed on the model's show page.
SHOW_PAGE_ATTRIBUTES = %i[
id
name
created_at
updated_at
].freeze
# FORM_ATTRIBUTES
# an array of attributes that will be displayed
# on the model's form (`new` and `edit`) pages.
FORM_ATTRIBUTES = %i[
name
].freeze
# COLLECTION_FILTERS
# a hash that defines filters that can be used while searching via the search
# field of the dashboard.
#
# For example to add an option to search for open resources by typing "open:"
# in the search field:
#
# COLLECTION_FILTERS = {
# open: ->(resources) { resources.where(open: true) }
# }.freeze
COLLECTION_FILTERS = {}.freeze
# Overwrite this method to customize how platform apps are displayed
# across all pages of the admin dashboard.
#
# def display_resource(platform_app)
# "PlatformApp ##{platform_app.id}"
# end
end

View file

@ -1,9 +1,14 @@
/* global axios */
import ApiClient from './ApiClient';
class Inboxes extends ApiClient {
constructor() {
super('inboxes', { accountScoped: true });
}
getAssignableAgents(inboxId) {
return axios.get(`${this.url}/${inboxId}/assignable_agents`);
}
}
export default new Inboxes();

View file

@ -29,46 +29,57 @@
&::before {
right: 0;
top: 60%;
}
}
.multiselect__content .multiselect__option {
font-size: $font-size-small;
font-weight: $font-weight-normal;
.multiselect__content {
max-width: 100%;
&.multiselect__option--highlight {
background: var(--white);
color: var(--color-body);
}
.multiselect__option {
font-size: $font-size-small;
font-weight: $font-weight-normal;
&.multiselect__option--highlight:hover {
background: var(--w-50);
color: var(--color-body);
&::after {
background: var(--w-50);
color: var(--s-600);
span {
display: inline-block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
}
}
&.multiselect__option--highlight::after {
background: transparent;
}
&.multiselect__option--selected {
background: var(--w-400);
color: var(--white);
&.multiselect__option--highlight {
background: var(--white);
color: var(--color-body);
}
&.multiselect__option--highlight:hover {
background: var(--w-600);
color: var(--white);
background: var(--w-50);
color: var(--color-body);
&::after {
background: transparent;
background: var(--w-50);
color: var(--s-600);
}
}
&.multiselect__option--highlight::after {
background: transparent;
}
&.multiselect__option--selected {
background: var(--w-400);
color: var(--white);
&.multiselect__option--highlight:hover {
background: var(--w-600);
color: var(--white);
&:hover {
&::after {
background: transparent;
color: var(--white);
}
&::after:hover {
color: var(--color-body);
}
}
@ -126,7 +137,6 @@
}
.sidebar-labels-wrap {
&.has-edited,
&:hover {
.multiselect {
@ -149,7 +159,6 @@
}
}
.multiselect-wrap--small {
$multiselect-height: 3.8rem;

View file

@ -14,7 +14,7 @@ $resolve-button-width: 13.2rem;
border: 1px solid var(--color-border);
border-radius: var(--space-smaller);
margin-right: var(--space-small);
width: 20.2rem;
width: 21.6rem;
.icon {
color: $medium-gray;
@ -25,11 +25,12 @@ $resolve-button-width: 13.2rem;
}
.multiselect {
border-radius: var(--border-radius-small);
margin: 0;
min-width: 0;
.multiselect__tags {
border: 0;
border-color: transparent;
}
}
}

View file

@ -116,7 +116,7 @@ export default {
return this.$store.getters['inboxes/getInbox'](this.activeInbox);
},
currentPage() {
return this.$store.getters['conversationPage/getCurrentPage'](
return this.$store.getters['conversationPage/getCurrentPageFilter'](
this.activeAssigneeTab
);
},
@ -199,6 +199,7 @@ export default {
},
updateAssigneeTab(selectedTab) {
if (this.activeAssigneeTab !== selectedTab) {
bus.$emit('clearSearchInput');
this.activeAssigneeTab = selectedTab;
if (!this.currentPage) {
this.fetchConversations();

View file

@ -2,14 +2,13 @@
<transition-group name="toast-fade" tag="div" class="ui-snackbar-container">
<woot-snackbar
v-for="snackMessage in snackMessages"
:key="snackMessage"
:message="snackMessage"
:key="snackMessage.key"
:message="snackMessage.message"
/>
</transition-group>
</template>
<script>
/* global bus */
import WootSnackbar from './Snackbar';
export default {
@ -31,7 +30,7 @@ export default {
mounted() {
bus.$on('newToastMessage', message => {
this.snackMessages.push(message);
this.snackMessages.push({ key: new Date().getTime(), message });
window.setTimeout(() => {
this.snackMessages.splice(0, 1);
}, this.duration);

View file

@ -33,7 +33,6 @@
</woot-button>
<woot-button
v-if="showDropDown"
class="icon--small"
:color-scheme="buttonClass"
:disabled="isLoading"
icon="ion-arrow-down-b"
@ -136,6 +135,7 @@ export default {
align-items: center;
justify-content: flex-end;
}
.dropdown-pane {
left: unset;
top: 4.2rem;
@ -143,7 +143,4 @@ export default {
right: 0;
max-width: 20rem;
}
.icon--small::v-deep .icon {
font-size: var(--font-size-small);
}
</style>

View file

@ -1,10 +1,7 @@
<template>
<div class="status">
<div class="status-view">
<div
:class="`status-badge status-badge__${currentUserAvailabilityStatus}`"
/>
<availability-status-badge :status="currentUserAvailabilityStatus" />
<div class="status-view--title">
{{ availabilityDisplayLabel }}
</div>
@ -28,7 +25,7 @@
:disabled="status.disabled"
@click="changeAvailabilityStatus(status.value)"
>
<span :class="`status-badge status-badge__${status.value}`" />
<availability-status-badge :status="status.value" />
{{ status.label }}
</button>
</woot-dropdown-item>
@ -48,6 +45,7 @@ import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
import AvailabilityStatusBadge from '../widgets/conversation/AvailabilityStatusBadge';
const AVAILABILITY_STATUS_KEYS = ['online', 'busy', 'offline'];
@ -55,6 +53,7 @@ export default {
components: {
WootDropdownMenu,
WootDropdownItem,
AvailabilityStatusBadge,
},
mixins: [clickaway],
@ -147,26 +146,6 @@ export default {
}
}
.status-badge {
width: var(--space-one);
height: var(--space-one);
margin-right: var(--space-micro);
display: inline-block;
border-radius: 50%;
&__online {
background: var(--g-400);
}
&__offline {
background: var(--b-600);
}
&__busy {
background: var(--y-700);
}
}
.status-change {
.dropdown-pane {
top: -132px;

View file

@ -0,0 +1,29 @@
<template>
<div :class="`status-badge status-badge__${status}`" />
</template>
<script>
export default {
props: {
status: { type: String, default: '' },
},
};
</script>
<style lang="scss" scoped>
@import '~dashboard/assets/scss/variables';
.status-badge {
width: var(--space-one);
height: var(--space-one);
margin-right: var(--space-micro);
display: inline-block;
border-radius: 50%;
&__online {
background: var(--g-400);
}
&__offline {
background: var(--b-600);
}
&__busy {
background: var(--y-700);
}
}
</style>

View file

@ -63,7 +63,7 @@ export default {
watch: {
'currentChat.inbox_id'(inboxId) {
if (inboxId) {
this.$store.dispatch('inboxMembers/fetch', { inboxId });
this.$store.dispatch('inboxAssignableAgents/fetch', { inboxId });
}
},
},

View file

@ -36,16 +36,24 @@
v-model="currentChat.meta.assignee"
:loading="uiFlags.isFetching"
:allow-empty="true"
:deselect-label="$t('CONVERSATION.ASSIGNMENT.REMOVE')"
deselect-label=""
:options="agentList"
:placeholder="$t('CONVERSATION.ASSIGNMENT.SELECT_AGENT')"
:select-label="$t('CONVERSATION.ASSIGNMENT.ASSIGN')"
select-label=""
label="name"
selected-label
track-by="id"
@select="assignAgent"
@remove="removeAgent"
>
<template slot="option" slot-scope="props">
<div class="option__desc">
<availability-status-badge
:status="props.option.availability_status"
/>
<span class="option__title">{{ props.option.name }}</span>
</div>
</template>
<span slot="noResult">{{ $t('AGENT_MGMT.SEARCH.NO_RESULTS') }}</span>
</multiselect>
</div>
@ -57,11 +65,13 @@
import { mapGetters } from 'vuex';
import MoreActions from './MoreActions';
import Thumbnail from '../Thumbnail';
import AvailabilityStatusBadge from '../conversation/AvailabilityStatusBadge';
export default {
components: {
MoreActions,
Thumbnail,
AvailabilityStatusBadge,
},
props: {
@ -83,8 +93,8 @@ export default {
computed: {
...mapGetters({
getAgents: 'inboxMembers/getMembersByInbox',
uiFlags: 'inboxMembers/getUIFlags',
getAgents: 'inboxAssignableAgents/getAssignableAgents',
uiFlags: 'inboxAssignableAgents/getUIFlags',
currentChat: 'getSelectedChat',
}),
@ -126,7 +136,6 @@ export default {
bus.$emit('newToastMessage', this.$t('CONVERSATION.CHANGE_AGENT'));
});
},
removeAgent() {},
},
};
@ -142,4 +151,17 @@ export default {
.conv-header {
flex: 0 0 var(--space-jumbo);
}
.option__desc {
display: flex;
align-items: center;
}
.option__desc {
&::v-deep .status-badge {
margin-right: var(--space-small);
min-width: 0;
flex-shrink: 0;
}
}
</style>

View file

@ -17,6 +17,7 @@ import ja from './locale/ja';
import ko from './locale/ko';
import ml from './locale/ml';
import nl from './locale/nl';
import no from './locale/no';
import pl from './locale/pl';
import pt from './locale/pt';
import pt_BR from './locale/pt_BR';
@ -28,7 +29,7 @@ import ta from './locale/ta';
import tr from './locale/tr';
import uk from './locale/uk';
import vi from './locale/vi';
import zh from './locale/zh';
import zh_CN from './locale/zh_CN';
import zh_TW from './locale/zh_TW';
export default {
@ -51,6 +52,7 @@ export default {
ko,
ml,
nl,
no,
pl,
pt,
pt_BR,
@ -62,6 +64,6 @@ export default {
tr,
uk,
vi,
zh,
zh_CN,
zh_TW,
};

View file

@ -0,0 +1,33 @@
import { default as _agentMgmt } from './agentMgmt.json';
import { default as _labelsMgmt } from './labelsMgmt.json';
import { default as _cannedMgmt } from './cannedMgmt.json';
import { default as _chatlist } from './chatlist.json';
import { default as _contact } from './contact.json';
import { default as _conversation } from './conversation.json';
import { default as _inboxMgmt } from './inboxMgmt.json';
import { default as _login } from './login.json';
import { default as _report } from './report.json';
import { default as _resetPassword } from './resetPassword.json';
import { default as _setNewPassword } from './setNewPassword.json';
import { default as _settings } from './settings.json';
import { default as _signup } from './signup.json';
import { default as _integrations } from './integrations.json';
import { default as _generalSettings } from './generalSettings.json';
export default {
..._agentMgmt,
..._cannedMgmt,
..._chatlist,
..._contact,
..._conversation,
..._inboxMgmt,
..._login,
..._report,
..._labelsMgmt,
..._resetPassword,
..._setNewPassword,
..._settings,
..._signup,
..._integrations,
..._generalSettings,
};

View file

@ -0,0 +1,33 @@
import { default as _agentMgmt } from './agentMgmt.json';
import { default as _labelsMgmt } from './labelsMgmt.json';
import { default as _cannedMgmt } from './cannedMgmt.json';
import { default as _chatlist } from './chatlist.json';
import { default as _contact } from './contact.json';
import { default as _conversation } from './conversation.json';
import { default as _inboxMgmt } from './inboxMgmt.json';
import { default as _login } from './login.json';
import { default as _report } from './report.json';
import { default as _resetPassword } from './resetPassword.json';
import { default as _setNewPassword } from './setNewPassword.json';
import { default as _settings } from './settings.json';
import { default as _signup } from './signup.json';
import { default as _integrations } from './integrations.json';
import { default as _generalSettings } from './generalSettings.json';
export default {
..._agentMgmt,
..._cannedMgmt,
..._chatlist,
..._contact,
..._conversation,
..._inboxMgmt,
..._login,
..._report,
..._labelsMgmt,
..._resetPassword,
..._setNewPassword,
..._settings,
..._signup,
..._integrations,
..._generalSettings,
};

View file

@ -28,7 +28,6 @@
</template>
<script>
/* global bus */
import { required, minLength, email } from 'vuelidate/lib/validators';
import Auth from '../../api/auth';
import { frontendURL } from '../../helper/URLHelper';
@ -74,8 +73,12 @@ export default {
this.showAlert(successMessage);
window.location = frontendURL('login');
})
.catch(() => {
this.showAlert(this.$t('RESET_PASSWORD.API.ERROR_MESSAGE'));
.catch(error => {
let errorMessage = this.$t('RESET_PASSWORD.API.ERROR_MESSAGE');
if (error?.response?.data?.message) {
errorMessage = error.response.data.message;
}
this.showAlert(errorMessage);
});
},
},

View file

@ -22,7 +22,17 @@
selected-label=""
:placeholder="$t('CONVERSATION_SIDEBAR.SELECT.PLACEHOLDER')"
:allow-empty="true"
/>
>
<template slot="option" slot-scope="props">
<div class="option__desc">
<availability-status-badge
:status="props.option.availability_status"
/>
<span class="option__title">{{ props.option.name }}</span>
</div>
</template>
<span slot="noResult">{{ $t('AGENT_MGMT.SEARCH.NO_RESULTS') }}</span>
</multiselect>
</div>
<div class="multiselect-wrap--small">
<label class="multiselect__label">
@ -38,7 +48,9 @@
selected-label=""
:placeholder="$t('CONVERSATION_SIDEBAR.SELECT.PLACEHOLDER')"
:allow-empty="true"
/>
>
<span slot="noResult">{{ $t('AGENT_MGMT.SEARCH.NO_RESULTS') }}</span>
</multiselect>
</div>
</div>
<div v-if="browser.browser_name" class="conversation--details">
@ -111,6 +123,8 @@ import ContactDetailsItem from './ContactDetailsItem.vue';
import ContactInfo from './contact/ContactInfo';
import ConversationLabels from './labels/LabelBox.vue';
import ContactCustomAttributes from './ContactCustomAttributes';
import AvailabilityStatusBadge from 'dashboard/components/widgets/conversation/AvailabilityStatusBadge.vue';
import flag from 'country-code-emoji';
export default {
@ -120,6 +134,7 @@ export default {
ContactDetailsItem,
ContactInfo,
ConversationLabels,
AvailabilityStatusBadge,
},
mixins: [alertMixin],
props: {
@ -140,8 +155,8 @@ export default {
...mapGetters({
currentChat: 'getSelectedChat',
teams: 'teams/getTeams',
getAgents: 'inboxMembers/getMembersByInbox',
uiFlags: 'inboxMembers/getUIFlags',
getAgents: 'inboxAssignableAgents/getAssignableAgents',
uiFlags: 'inboxAssignableAgents/getUIFlags',
}),
currentConversationMetaData() {
return this.$store.getters[
@ -292,6 +307,14 @@ export default {
}
}
.multiselect-wrap--small {
&::v-deep .multiselect__element {
span {
width: 100%;
}
}
}
.close-button {
position: absolute;
right: $space-normal;
@ -342,4 +365,14 @@ export default {
.multiselect__label {
margin-bottom: var(--space-smaller);
}
.option__desc {
display: flex;
align-items: center;
&::v-deep .status-badge {
margin-right: var(--space-small);
min-width: 0;
flex-shrink: 0;
}
}
</style>

View file

@ -82,6 +82,7 @@ export default {
...mapGetters({
conversations: 'conversationSearch/getConversations',
uiFlags: 'conversationSearch/getUIFlags',
currentPage: 'conversationPage/getCurrentPage',
}),
resultsCount() {
return this.conversations.length;
@ -111,10 +112,16 @@ export default {
this.$store.dispatch('conversationSearch/get', { q: newValue });
}, 1000);
},
currentPage() {
this.clearSearchTerm();
},
},
mounted() {
this.$store.dispatch('conversationSearch/get', { q: '' });
bus.$on('clearSearchInput', () => {
this.clearSearchTerm();
});
},
methods: {
@ -124,6 +131,9 @@ export default {
closeSearch() {
this.showSearchBox = false;
},
clearSearchTerm() {
this.searchTerm = '';
},
},
};
</script>

View file

@ -18,6 +18,7 @@ import conversationTypingStatus from './modules/conversationTypingStatus';
import globalConfig from 'shared/store/globalConfig';
import inboxes from './modules/inboxes';
import inboxMembers from './modules/inboxMembers';
import inboxAssignableAgents from './modules/inboxAssignableAgents';
import integrations from './modules/integrations';
import labels from './modules/labels';
import reports from './modules/reports';
@ -46,6 +47,7 @@ export default new Vuex.Store({
globalConfig,
inboxes,
inboxMembers,
inboxAssignableAgents,
integrations,
labels,
reports,

View file

@ -18,9 +18,12 @@ export const getters = {
getHasEndReached: $state => filter => {
return $state.hasEndReached[filter];
},
getCurrentPage: $state => filter => {
getCurrentPageFilter: $state => filter => {
return $state.currentPage[filter];
},
getCurrentPage: $state => {
return $state.currentPage;
},
};
export const actions = {

View file

@ -13,7 +13,7 @@ export const applyPageFilters = (conversation, filters) => {
labels: chatLabels = [],
meta = {},
} = conversation;
const { team = {} } = meta;
const team = meta.team || {};
const { id: chatTeamId } = team;
const filterByStatus = chatStatus === status;
let shouldFilter = filterByStatus;

View file

@ -0,0 +1,62 @@
import Vue from 'vue';
import InboxesAPI from 'dashboard/api/inboxes.js';
const state = {
records: {},
uiFlags: {
isFetching: false,
},
};
export const types = {
SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG: 'SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG',
SET_INBOX_ASSIGNABLE_AGENTS: 'SET_INBOX_ASSIGNABLE_AGENTS',
};
export const getters = {
getAssignableAgents: $state => inboxId => {
const allAgents = $state.records[inboxId] || [];
const verifiedAgents = allAgents.filter(record => record.confirmed);
return verifiedAgents;
},
getUIFlags($state) {
return $state.uiFlags;
},
};
export const actions = {
async fetch({ commit }, { inboxId }) {
commit(types.SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG, { isFetching: true });
try {
const {
data: { payload },
} = await InboxesAPI.getAssignableAgents(inboxId);
commit(types.SET_INBOX_ASSIGNABLE_AGENTS, { inboxId, members: payload });
} catch (error) {
throw new Error(error);
} finally {
commit(types.SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG, { isFetching: false });
}
},
};
export const mutations = {
[types.SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG]($state, data) {
$state.uiFlags = {
...$state.uiFlags,
...data,
};
},
[types.SET_INBOX_ASSIGNABLE_AGENTS]: ($state, { inboxId, members }) => {
Vue.set($state.records, inboxId, members);
},
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View file

@ -9,12 +9,25 @@ describe('#getters', () => {
all: 3,
},
};
expect(getters.getCurrentPage(state)('me')).toEqual(1);
expect(getters.getCurrentPage(state)('unassigned')).toEqual(2);
expect(getters.getCurrentPage(state)('all')).toEqual(3);
expect(getters.getCurrentPage(state)).toHaveProperty('me');
expect(getters.getCurrentPage(state)).toHaveProperty('unassigned');
expect(getters.getCurrentPage(state)).toHaveProperty('all');
});
it('getCurrentPage', () => {
it('getCurrentPageFilter', () => {
const state = {
currentPage: {
me: 1,
unassigned: 2,
all: 3,
},
};
expect(getters.getCurrentPageFilter(state)('me')).toEqual(1);
expect(getters.getCurrentPageFilter(state)('unassigned')).toEqual(2);
expect(getters.getCurrentPageFilter(state)('all')).toEqual(3);
});
it('getHasEndReached', () => {
const state = {
hasEndReached: {
me: false,

View file

@ -0,0 +1,36 @@
import axios from 'axios';
import { actions, types } from '../../inboxAssignableAgents';
import agentsData from './fixtures';
const commit = jest.fn();
global.axios = axios;
jest.mock('axios');
describe('#actions', () => {
describe('#fetch', () => {
it('sends correct actions if API is success', async () => {
axios.get.mockResolvedValue({
data: { payload: agentsData },
});
await actions.fetch({ commit }, { inboxId: 1 });
expect(commit.mock.calls).toEqual([
[types.SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG, { isFetching: true }],
[
types.SET_INBOX_ASSIGNABLE_AGENTS,
{ inboxId: 1, members: agentsData },
],
[types.SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG, { isFetching: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.get.mockRejectedValue({ message: 'Incorrect header' });
await expect(actions.fetch({ commit }, { inboxId: 1 })).rejects.toThrow(
Error
);
expect(commit.mock.calls).toEqual([
[types.SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG, { isFetching: true }],
[types.SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG, { isFetching: false }],
]);
});
});
});

View file

@ -0,0 +1,28 @@
export default [
{
id: 1,
provider: 'email',
uid: 'agent1@chatwoot.com',
name: 'Agent1',
email: 'agent1@chatwoot.com',
account_id: 1,
created_at: '2019-11-18T02:21:06.225Z',
updated_at: '2019-12-20T07:43:35.794Z',
pubsub_token: 'random-1',
role: 'agent',
confirmed: true,
},
{
id: 2,
provider: 'email',
uid: 'agent2@chatwoot.com',
name: 'Agent2',
email: 'agent2@chatwoot.com',
account_id: 1,
created_at: '2019-11-18T02:21:06.225Z',
updated_at: '2019-12-20T07:43:35.794Z',
pubsub_token: 'random-2',
role: 'agent',
confirmed: true,
},
];

View file

@ -0,0 +1,24 @@
import { getters } from '../../teamMembers';
import agentsData from './fixtures';
describe('#getters', () => {
it('getAssignableAgents', () => {
const state = {
records: {
1: [agentsData[0]],
},
};
expect(getters.getTeamMembers(state)(1)).toEqual([agentsData[0]]);
});
it('getUIFlags', () => {
const state = {
uiFlags: {
isFetching: false,
},
};
expect(getters.getUIFlags(state)).toEqual({
isFetching: false,
});
});
});

View file

@ -0,0 +1,16 @@
import { mutations, types } from '../../inboxAssignableAgents';
import agentsData from './fixtures';
describe('#mutations', () => {
describe('#SET_INBOX_ASSIGNABLE_AGENTS', () => {
it('Adds inbox members to records', () => {
const state = { records: {} };
mutations[types.SET_INBOX_ASSIGNABLE_AGENTS](state, {
members: [...agentsData],
inboxId: 1,
});
expect(state.records).toEqual({ 1: agentsData });
});
});
});

View file

@ -17,6 +17,7 @@ import { default as ja } from './locale/ja.json';
import { default as ko } from './locale/ko.json';
import { default as ml } from './locale/ml.json';
import { default as nl } from './locale/nl.json';
import { default as no } from './locale/no.json';
import { default as pl } from './locale/pl.json';
import { default as pt } from './locale/pt.json';
import { default as pt_BR } from './locale/pt_BR.json';
@ -28,7 +29,8 @@ import { default as ta } from './locale/ta.json';
import { default as tr } from './locale/tr.json';
import { default as uk } from './locale/uk.json';
import { default as vi } from './locale/vi.json';
import { default as zh } from './locale/zh.json';
import { default as zh_CN } from './locale/zh_CN.json';
import { default as zh_TW } from './locale/zh_TW.json';
export default {
ar,
@ -50,6 +52,7 @@ export default {
ko,
ml,
nl,
no,
pl,
pt,
pt_BR,
@ -61,5 +64,6 @@ export default {
tr,
uk,
vi,
zh,
zh_CN,
zh_TW,
};

View file

@ -23,6 +23,10 @@ class InboxPolicy < ApplicationPolicy
true
end
def assignable_agents?
true
end
def create?
@account_user.administrator?
end

View file

@ -0,0 +1,5 @@
json.payload do
json.array! @assignable_agents do |agent|
json.partial! 'api/v1/models/agent.json.jbuilder', resource: agent
end
end

View file

@ -16,6 +16,7 @@ as defined by the routes in the `admin/` namespace
users: 'ion ion-person-stalker',
super_admins: 'ion ion-unlocked',
access_tokens: 'ion-key',
platform_apps: 'ion ion-social-buffer',
installation_configs: 'ion ion-settings'
}
%>
@ -32,7 +33,7 @@ as defined by the routes in the `admin/` namespace
<%= link_to "Dashboard", super_admin_root_url %>
</li>
<% Administrate::Namespace.new(namespace).resources.each do |resource| %>
<% next if ["account_users", "agent_bots","dashboard", "devise/sessions"].include? resource.resource %>
<% next if ["account_users", "agent_bots", "dashboard", "devise/sessions"].include? resource.resource %>
<li class="navigation__link navigation__link--<%= nav_link_state(resource) %>">
<i class="<%= sidebar_icons[resource.resource.to_sym] %>"></i>
<%= link_to(

View file

@ -1,5 +1,5 @@
shared: &shared
version: '1.15.0'
version: '1.15.1'
development:
<<: *shared

View file

@ -29,8 +29,10 @@ LANGUAGES_CONFIG = {
24 => { name: 'čeština (cs)', iso_639_3_code: 'ces', iso_639_1_code: 'cs', enabled: true },
25 => { name: 'suomi, suomen kieli (fi)', iso_639_3_code: 'fin', iso_639_1_code: 'fi', enabled: true },
26 => { name: 'Bahasa Indonesia (id)', iso_639_3_code: 'ind', iso_639_1_code: 'id', enabled: true },
27 => { name: 'Svenska (sv)', iso_639_3_code: 'swe', iso_639_1_code: 'sv', enabled: true }
27 => { name: 'Svenska (sv)', iso_639_3_code: 'swe', iso_639_1_code: 'sv', enabled: true },
28 => { name: 'magyar nyelv (hu)', iso_639_3_code: 'hun', iso_639_1_code: 'hu', enabled: true },
29 => { name: 'norsk (no)', iso_639_3_code: 'nor', iso_639_1_code: 'no', enabled: true },
30 => { name: '中文 (zh-CN)', iso_639_3_code: 'zho', iso_639_1_code: 'zh_CN', enabled: true }
}.filter { |_key, val| val[:enabled] }.freeze
Rails.configuration.i18n.available_locales = LANGUAGES_CONFIG.map { |_index, lang| lang[:iso_639_1_code].to_sym }

View file

@ -1,5 +1,5 @@
#Additional translations at https://github.com/plataformatec/devise/wiki/I18n
zh-CN:
zh_CN:
devise:
confirmations:
confirmed: "您的电子邮件地址已成功确认。"

View file

@ -16,7 +16,7 @@
#'true': 'foo'
#To learn more, please read the Rails Internationalization guide
#available at https://guides.rubyonrails.org/i18n.html.
zh-CN:
zh_CN:
hello: "您好世界"
messages:
reset_password_success: 哇!密码重置请求成功。请检查您的邮件获取说明。

View file

@ -88,6 +88,7 @@ Rails.application.routes.draw do
end
resources :inboxes, only: [:index, :create, :update, :destroy] do
get :assignable_agents, on: :member
post :set_agent_bot, on: :member
end
resources :inbox_members, only: [:create, :show], param: :inbox_id
@ -241,6 +242,7 @@ Rails.application.routes.draw do
# resources that doesn't appear in primary navigation in super admin
resources :account_users, only: [:new, :create, :destroy]
resources :agent_bots, only: [:index, :new, :create, :show, :edit, :update]
resources :platform_apps, only: [:index, :new, :create, :show, :edit, :update]
end
authenticated :super_admin do
mount Sidekiq::Web => '/monitoring/sidekiq'

View file

@ -1,6 +1,6 @@
{
"name": "@chatwoot/chatwoot",
"version": "1.15.0",
"version": "1.15.1",
"license": "MIT",
"scripts": {
"eslint": "eslint app/javascript --fix",

View file

@ -3,11 +3,11 @@ require 'rails_helper'
describe ::ContactBuilder do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:contact) { create(:contact, account: account) }
let(:contact) { create(:contact, account: account, identifier: '123') }
let(:existing_contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox) }
describe '#perform' do
it 'doesnot create contact if it already exist' do
it 'doesnot create contact if it already exist with source id' do
contact_inbox = described_class.new(
source_id: existing_contact_inbox.source_id,
inbox: inbox,
@ -21,7 +21,7 @@ describe ::ContactBuilder do
expect(contact_inbox.contact.id).to be(contact.id)
end
it 'creates contact if contact doesnot exist' do
it 'creates contact if contact doesnot exist with source id' do
contact_inbox = described_class.new(
source_id: '123456',
inbox: inbox,
@ -36,5 +36,48 @@ describe ::ContactBuilder do
expect(contact_inbox.contact.name).to eq('Contact')
expect(contact_inbox.inbox_id).to eq(inbox.id)
end
it 'doesnot create contact if it already exist with identifier' do
contact_inbox = described_class.new(
source_id: '123456',
inbox: inbox,
contact_attributes: {
name: 'Contact',
identifier: contact.identifier,
phone_number: contact.phone_number,
email: 'testemail@example.com'
}
).perform
expect(contact_inbox.contact.id).to be(contact.id)
end
it 'doesnot create contact if it already exist with email' do
contact_inbox = described_class.new(
source_id: '123456',
inbox: inbox,
contact_attributes: {
name: 'Contact',
phone_number: '+1234567890',
email: contact.email
}
).perform
expect(contact_inbox.contact.id).to be(contact.id)
end
it 'doesnot create contact if it already exist with phone number' do
contact_inbox = described_class.new(
source_id: '123456',
inbox: inbox,
contact_attributes: {
name: 'Contact',
phone_number: contact.phone_number,
email: 'testemail@example.com'
}
).perform
expect(contact_inbox.contact.id).to be(contact.id)
end
end
end

View file

@ -42,6 +42,38 @@ RSpec.describe 'Inboxes API', type: :request do
end
end
describe 'GET /api/v1/accounts/{account.id}/inboxes/{inbox.id}/assignable_agents' do
let(:inbox) { create(:inbox, account: account) }
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/assignable_agents"
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) }
let(:admin) { create(:user, account: account, role: :administrator) }
before do
create(:inbox_member, user: agent, inbox: inbox)
end
it 'returns all assignable inbox members along with administrators' do
get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/assignable_agents",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
response_data = JSON.parse(response.body, symbolize_names: true)[:payload]
expect(response_data.size).to eq(2)
expect(response_data.pluck(:role)).to include('agent', 'administrator')
end
end
end
describe 'DELETE /api/v1/accounts/{account.id}/inboxes/:id' do
let(:inbox) { create(:inbox, account: account) }

View file

@ -0,0 +1,25 @@
require 'rails_helper'
RSpec.describe 'Super Admin platform app API', type: :request do
let(:super_admin) { create(:super_admin) }
describe 'GET /super_admin/platform_apps' do
context 'when it is an unauthenticated super admin' do
it 'returns unauthorized' do
get '/super_admin/platform_apps'
expect(response).to have_http_status(:redirect)
end
end
context 'when it is an authenticated super admin' do
let!(:platform_app) { create(:platform_app) }
it 'shows the list of users' do
sign_in super_admin
get '/super_admin/platform_apps'
expect(response).to have_http_status(:success)
expect(response.body).to include(platform_app.name)
end
end
end
end