Compare commits

..

7 commits

Author SHA1 Message Date
tejaswini chile
91fc619ca1 SSO test with authenticable 2022-09-27 13:54:47 +05:30
tejaswini chile
6e16670396 save session for saml login 2022-09-26 14:20:03 +05:30
tejaswini chile
d4587b01f7 SSO session 2022-09-21 19:02:13 +05:30
tejaswini chile
834f880fa8 test with ngrok 2022-09-20 13:52:32 +05:30
tejaswini chile
83660b47f7 idp cert changes 2022-09-16 14:31:13 +05:30
tejaswini chile
e94db861c5 SAML changes to the controller 2022-09-15 17:17:23 +05:30
tejaswini chile
fb65f18b5f SSO test with onelogin 2022-09-12 15:01:51 +05:30
1537 changed files with 11293 additions and 59333 deletions

View file

@ -19,7 +19,6 @@ defaults: &defaults
- COVERAGE: true
- LOG_LEVEL: warn
parallelism: 4
resource_class: large
jobs:
build:
@ -123,11 +122,9 @@ jobs:
mkdir -p coverage
~/tmp/cc-test-reporter before-build
TESTFILES=$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings)
bundle exec rspec --format progress \
--format RspecJunitFormatter \
--out ~/tmp/test-results/rspec.xml \
bundle exec rspec --profile 10 \
--out test-results/rspec/rspec.xml \
-- ${TESTFILES}
no_output_timeout: 30m
- run:
name: Code Climate Test Coverage
command: |
@ -140,7 +137,7 @@ jobs:
~/tmp/cc-test-reporter before-build
TESTFILES=$(circleci tests glob **/specs/*.spec.js | circleci tests split --split-by=timings)
yarn test:coverage --profile 10 \
--out ~/tmp/test-results/yarn.xml \
--out test-results/frontend_specs/rspec.xml \
-- ${TESTFILES}
- run:
name: Code Climate Test Coverage

View file

@ -54,5 +54,3 @@ exclude_patterns:
- 'app/javascript/widget/i18n/index.js'
- 'app/javascript/survey/i18n/index.js'
- 'app/javascript/shared/constants/locales.js'
- 'app/javascript/dashboard/helper/specs/macrosFixtures.js'
- 'app/javascript/dashboard/routes/dashboard/settings/macros/constants.js'

View file

@ -7,8 +7,8 @@ end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
indent_style = space
indent_style = spaces
tab_width = 2
[*.{rb,erb,js,coffee,json,yml,css,scss,sh,markdown,md,html}]
[{*.{rb,erb,js,coffee,json,yml,css,scss,sh,markdown,md,html}]
indent_size = 2

View file

@ -3,8 +3,6 @@ SECRET_KEY_BASE=replace_with_lengthy_secure_hex
# Replace with the URL you are planning to use for your app
FRONTEND_URL=http://0.0.0.0:3000
# To use a dedicated URL for help center pages
# HELPCENTER_URL=http://0.0.0.0:3000
# If the variable is set, all non-authenticated pages would fallback to the default locale.
# Whenever a new account is created, the default language will be DEFAULT_LOCALE instead of en
@ -34,11 +32,6 @@ REDIS_SENTINELS=
# You can find list of master using "SENTINEL masters" command
REDIS_SENTINEL_MASTER_NAME=
# By default Chatwoot will pass REDIS_PASSWORD as the password value for sentinels
# Use the following environment variable to customize passwords for sentinels.
# Use empty string if sentinels are configured with out passwords
# REDIS_SENTINEL_PASSWORD=
# Redis premium breakage in heroku fix
# enable the following configuration
# ref: https://github.com/chatwoot/chatwoot/issues/2420
@ -56,14 +49,13 @@ RAILS_MAX_THREADS=5
# The email from which all outgoing emails are sent
# could user either `email@yourdomain.com` or `BrandName <email@yourdomain.com>`
MAILER_SENDER_EMAIL=Chatwoot <accounts@chatwoot.com>
MAILER_SENDER_EMAIL="Chatwoot <accounts@chatwoot.com>"
#SMTP domain key is set up for HELO checking
SMTP_DOMAIN=chatwoot.com
# Set the value to "mailhog" if using docker-compose for development environments,
# the default value is set "mailhog" and is used by docker-compose for development environments,
# Set the value as "localhost" or your SMTP address in other environments
# If SMTP_ADDRESS is empty, Chatwoot would try to use sendmail(postfix)
SMTP_ADDRESS=
SMTP_ADDRESS=mailhog
SMTP_PORT=1025
SMTP_USERNAME=
SMTP_PASSWORD=

View file

@ -6,7 +6,6 @@ labels: 'Bug'
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
@ -17,11 +16,11 @@ Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See the error
4. See error
**Expected behavior**
Share a clear and concise description of what you expected to happen.
A clear and concise description of what you expected to happen.
**Screenshots**
@ -29,50 +28,27 @@ If applicable, add screenshots to help explain your problem.
**Browser logs**
Share the browser logs to debug the issue further.
Share the browser logs to debug the issue further
**Server logs**
Share the server logs to debug the issue further.
Share the server logs to debug the issue further
**Environment**
Describe whether you are using Chatwoot Cloud (app.chatwoot.com) or a self-hosted installation of Chatwoot. If you are using a self-hosted installation of Chatwoot, describe the type of deployment (Docker/Linux VM installation/Heroku/Kubernetes/Other).
Describe whether you are using Chatwoot Cloud (app.chatwoot.com) or a self hosted installation of Chatwoot. If you are using a self hosted installation of Chatwoot describe the type of deployment (Docker/Linux VM installation/Heroku)
- [ ] app.chatwoot.com (Chatwoot Cloud)
- [ ] Self-hosted
- - [ ] Linux VM
- - [ ] Docker
- - [ ] Kubernetes
- - [ ] Heroku
- - [ ] Other (Please specify)
**Desktop (please complete the following information)** (If applicable)
- OS: [e.g. Linux, Windows, MacOS]
- Browser [e.g. chrome, firefox, safari]
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information)** (If applicable)
- Device: [e.g. iPhone6, Pixel7]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, firefox, safari]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Docker** (If applicable)
Please share the output of the following.
- `docker version`
- `docker info`
- `docker-compose version`
**Cloud Provider** (If applicable)
- [ ] AWS
- [ ] GCP
- [ ] Azure
- [ ] DigitalOcean
- [ ] Others
**Additional context**
Add any other context about the problem here.

View file

@ -2,7 +2,8 @@
## Description
Please include a summary of the change and issue(s) fixed. Also, mention relevant motivation, context, and any dependencies that this change requires.
Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change.
Fixes # (issue)
## Type of change
@ -11,18 +12,18 @@ Please delete options that are not relevant.
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality not to work as expected)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] This change requires a documentation update
## How Has This Been Tested?
Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration.
Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration
## Checklist:
- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have performed a self-review of my own code
- [ ] I have commented on my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings

View file

@ -23,8 +23,6 @@ jobs:
run: |
wget https://get.chatwoot.app/linux/install.sh
chmod +x install.sh
#fix for postgtres not starting automatically in gh action env
sed -i '/function configure_db() {/a sudo service postgresql start' install.sh
- name: create input file
run: |
@ -35,6 +33,20 @@ jobs:
run: |
sudo ./install.sh --install < input
# temp fix for postgresql not starting
# automatically in gh action env
- name: start postgresql service
if: always()
run: |
sudo service postgresql start
#re-running the installer again
- name: Run the installer again
if: always()
run: |
sudo ./install.sh --install < input
# disabling http verify for now as http
# access to port 3000 fails in gh action env
# - name: Verify

View file

@ -58,6 +58,5 @@ jobs:
with:
context: .
file: docker/Dockerfile
platforms: linux/amd64
push: true
tags: ${{ env.DOCKER_TAG }}

5
.gitignore vendored
View file

@ -39,6 +39,9 @@ public/packs*
*.un~
.jest-cache
#VS Code files
.vscode
# ignore jetbrains IDE files
.idea
@ -60,5 +63,3 @@ test/cypress/videos/*
/config/master.key
/config/*.enc
.vscode/settings.json

View file

@ -1,32 +0,0 @@
{
"recommendations": [
// Spell check
"streetsidesoftware.code-spell-checker",
// Better Comments
"aaron-bond.better-comments",
// Rails Test Runner
"davidpallinder.rails-test-runner",
// Eslint
"dbaeumer.vscode-eslint",
// Auto Close Tag
"formulahendry.auto-close-tag",
// Auto Rename Tag
"formulahendry.auto-rename-tag",
// Hight light colors
"naumovs.color-highlight",
// GitLens
"eamodio.gitlens",
// Ruby
"rebornix.ruby",
// Vue
"octref.vetur",
// Prettier
"esbenp.prettier-vscode",
// Dot Env
"mikestead.dotenv",
// HTML CSS Support
"ecmel.vscode-html-css",
// Tailwind CSS Intellisense
"bradlc.vscode-tailwindcss",
]
}

View file

@ -1 +0,0 @@
{}

11
Gemfile
View file

@ -4,7 +4,7 @@ ruby '3.0.4'
##-- base gems for rails --##
gem 'rack-cors', require: 'rack/cors'
gem 'rails', '~> 6.1', '>= 6.1.6.1'
gem 'rails', '~>6.1'
# Reduces boot times through caching; required in config/boot.rb
gem 'bootsnap', require: false
@ -56,7 +56,7 @@ gem 'activerecord-import'
gem 'dotenv-rails'
gem 'foreman'
gem 'puma'
gem 'webpacker', '~> 5.4', '>= 5.4.3'
gem 'webpacker', '~> 5.x'
# metrics on heroku
gem 'barnes'
@ -94,7 +94,7 @@ gem 'ddtrace'
gem 'elastic-apm'
gem 'newrelic_rpm'
gem 'scout_apm'
gem 'sentry-rails', '~> 5.3', '>= 5.3.1'
gem 'sentry-rails', '~> 5.3'
gem 'sentry-ruby', '~> 5.3'
gem 'sentry-sidekiq', '~> 5.3'
@ -135,6 +135,8 @@ gem 'stripe'
## to populate db with sample data
gem 'faker'
gem 'ruby-saml', '= 1.11'
group :production, :staging do
# we dont want request timing out in development while using byebug
gem 'rack-timeout'
@ -174,8 +176,7 @@ group :development, :test do
gem 'listen'
gem 'mock_redis'
gem 'pry-rails'
gem 'rspec_junit_formatter'
gem 'rspec-rails', '~> 5.0.3'
gem 'rspec-rails', '~> 5.0.0'
gem 'rubocop', require: false
gem 'rubocop-performance', require: false
gem 'rubocop-rails', require: false

View file

@ -135,7 +135,7 @@ GEM
byebug (11.1.3)
climate_control (1.1.1)
coderay (1.1.3)
commonmarker (0.23.6)
commonmarker (0.23.5)
concurrent-ruby (1.1.10)
connection_pool (2.2.5)
crack (0.4.5)
@ -286,9 +286,9 @@ GEM
google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0)
google-protobuf (3.21.7)
google-protobuf (3.21.7-x86_64-darwin)
google-protobuf (3.21.7-x86_64-linux)
google-protobuf (3.21.2)
google-protobuf (3.21.2-x86_64-darwin)
google-protobuf (3.21.2-x86_64-linux)
googleapis-common-protos (1.3.12)
google-protobuf (~> 3.14)
googleapis-common-protos-types (~> 1.2)
@ -398,7 +398,7 @@ GEM
llhttp-ffi (0.4.0)
ffi-compiler (~> 1.0)
rake (~> 13.0)
loofah (2.19.1)
loofah (2.18.0)
crass (~> 1.0.2)
nokogiri (>= 1.5.9)
mail (2.7.1)
@ -427,14 +427,14 @@ GEM
netrc (0.11.0)
newrelic_rpm (8.9.0)
nio4r (2.5.8)
nokogiri (1.13.10)
nokogiri (1.13.8)
mini_portile2 (~> 2.8.0)
racc (~> 1.4)
nokogiri (1.13.10-arm64-darwin)
nokogiri (1.13.8-arm64-darwin)
racc (~> 1.4)
nokogiri (1.13.10-x86_64-darwin)
nokogiri (1.13.8-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.13.10-x86_64-linux)
nokogiri (1.13.8-x86_64-linux)
racc (~> 1.4)
oauth (0.5.10)
orm_adapter (0.5.0)
@ -459,7 +459,7 @@ GEM
pundit (2.2.0)
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.6.1)
racc (1.6.0)
rack (2.2.4)
rack-attack (6.6.1)
rack (>= 1.0, < 3)
@ -488,8 +488,8 @@ GEM
rails-dom-testing (2.0.3)
activesupport (>= 4.2.0)
nokogiri (>= 1.6)
rails-html-sanitizer (1.4.4)
loofah (~> 2.19, >= 2.19.1)
rails-html-sanitizer (1.4.3)
loofah (~> 2.3)
railties (6.1.6.1)
actionpack (= 6.1.6.1)
activesupport (= 6.1.6.1)
@ -536,8 +536,6 @@ GEM
rspec-mocks (~> 3.10)
rspec-support (~> 3.10)
rspec-support (3.11.0)
rspec_junit_formatter (0.6.0)
rspec-core (>= 2, < 4, != 2.12.0)
rubocop (1.31.2)
json (~> 2.3)
parallel (~> 1.10)
@ -560,6 +558,8 @@ GEM
rubocop-rspec (2.12.1)
rubocop (~> 1.31)
ruby-progressbar (1.11.0)
ruby-saml (1.11.0)
nokogiri (>= 1.5.10)
ruby-vips (2.1.4)
ffi (~> 1.12)
ruby2_keywords (0.0.5)
@ -765,20 +765,20 @@ DEPENDENCIES
rack-attack
rack-cors
rack-timeout
rails (~> 6.1, >= 6.1.6.1)
rails (~> 6.1)
redis
redis-namespace
responders
rest-client
rspec-rails (~> 5.0.3)
rspec_junit_formatter
rspec-rails (~> 5.0.0)
rubocop
rubocop-performance
rubocop-rails
rubocop-rspec
ruby-saml (= 1.11)
scout_apm
seed_dump
sentry-rails (~> 5.3, >= 5.3.1)
sentry-rails (~> 5.3)
sentry-ruby (~> 5.3)
sentry-sidekiq (~> 5.3)
shoulda-matchers
@ -799,7 +799,7 @@ DEPENDENCIES
valid_email2
web-console
webmock
webpacker (~> 5.4, >= 5.4.3)
webpacker (~> 5.x)
webpush
wisper (= 2.0.0)
working_hours

View file

@ -41,24 +41,16 @@
"formation": {
"web": {
"quantity": 1,
"size": "basic"
"size": "FREE"
},
"worker": {
"quantity": 1,
"size": "basic"
"size": "FREE"
}
},
"stack": "heroku-20",
"image": "heroku/ruby",
"addons": [
{
"plan": "heroku-redis:mini"
},
{
"plan": "heroku-postgresql:mini"
}
],
"stack": "heroku-20",
"addons": [ "heroku-redis", "heroku-postgresql"],
"buildpacks": [
{
"url": "heroku/ruby"

View file

@ -1,47 +1,25 @@
# This Builder will create a contact and contact inbox with specified attributes.
# If an existing identified contact exisits, it will be returned.
# for contact inbox logic it uses the contact inbox builder
class ContactInboxWithContactBuilder
pattr_initialize [:inbox!, :contact_attributes!, :source_id, :hmac_verified]
class ContactBuilder
pattr_initialize [:source_id!, :inbox!, :contact_attributes!, :hmac_verified]
def perform
find_or_create_contact_and_contact_inbox
# in case of race conditions where contact is created by another thread
# we will try to find the contact and create a contact inbox
rescue ActiveRecord::RecordNotUnique
find_or_create_contact_and_contact_inbox
end
contact_inbox = inbox.contact_inboxes.find_by(source_id: source_id)
return contact_inbox if contact_inbox
def find_or_create_contact_and_contact_inbox
@contact_inbox = inbox.contact_inboxes.find_by(source_id: source_id) if source_id.present?
return @contact_inbox if @contact_inbox
ActiveRecord::Base.transaction(requires_new: true) do
build_contact_with_contact_inbox
update_contact_avatar(@contact) unless @contact.avatar.attached?
@contact_inbox
end
build_contact_inbox
end
private
def build_contact_with_contact_inbox
@contact = find_contact || create_contact
@contact_inbox = create_contact_inbox
end
def account
@account ||= inbox.account
end
def create_contact_inbox
ContactInboxBuilder.new(
contact: @contact,
inbox: @inbox,
source_id: @source_id,
hmac_verified: hmac_verified
).perform
def create_contact_inbox(contact)
::ContactInbox.create_with(hmac_verified: hmac_verified || false).find_or_create_by!(
contact_id: contact.id,
inbox_id: inbox.id,
source_id: source_id
)
end
def update_contact_avatar(contact)
@ -83,4 +61,16 @@ class ContactInboxWithContactBuilder
account.contacts.find_by(phone_number: phone_number)
end
def build_contact_inbox
ActiveRecord::Base.transaction do
contact = find_contact || create_contact
contact_inbox = create_contact_inbox(contact)
update_contact_avatar(contact)
contact_inbox
rescue StandardError => e
Rails.logger.error e
raise e
end
end
end

View file

@ -1,12 +1,13 @@
# This Builder will create a contact inbox with specified attributes. If the contact inbox already exists, it will be returned.
# For Specific Channels like whatsapp, email etc . it smartly generated appropriate the source id when none is provided.
class ContactInboxBuilder
pattr_initialize [:contact, :inbox, :source_id, { hmac_verified: false }]
pattr_initialize [:contact_id!, :inbox_id!, :source_id]
def perform
@source_id ||= generate_source_id
create_contact_inbox if source_id.present?
@contact = Contact.find(contact_id)
@inbox = @contact.account.inboxes.find(inbox_id)
return unless ['Channel::TwilioSms', 'Channel::Sms', 'Channel::Email', 'Channel::Api', 'Channel::Whatsapp'].include? @inbox.channel_type
source_id = @source_id || generate_source_id
create_contact_inbox(source_id) if source_id.present?
end
private
@ -18,37 +19,23 @@ class ContactInboxBuilder
when 'Channel::Whatsapp'
wa_source_id
when 'Channel::Email'
email_source_id
when 'Channel::Sms'
phone_source_id
when 'Channel::Api', 'Channel::WebWidget'
SecureRandom.uuid
else
raise "Unsupported operation for this channel: #{@inbox.channel_type}"
end
end
def email_source_id
raise ActionController::ParameterMissing, 'contact email' unless @contact.email
@contact.email
end
def phone_source_id
raise ActionController::ParameterMissing, 'contact phone number' unless @contact.phone_number
when 'Channel::Sms'
@contact.phone_number
when 'Channel::Api'
SecureRandom.uuid
end
end
def wa_source_id
raise ActionController::ParameterMissing, 'contact phone number' unless @contact.phone_number
return unless @contact.phone_number
# whatsapp doesn't want the + in e164 format
@contact.phone_number.delete('+').to_s
"#{@contact.phone_number}.delete('+')"
end
def twilio_source_id
raise ActionController::ParameterMissing, 'contact phone number' unless @contact.phone_number
return unless @contact.phone_number
case @inbox.channel.medium
when 'sms'
@ -58,11 +45,11 @@ class ContactInboxBuilder
end
end
def create_contact_inbox
::ContactInbox.create_with(hmac_verified: hmac_verified || false).find_or_create_by!(
def create_contact_inbox(source_id)
::ContactInbox.find_or_create_by!(
contact_id: @contact.id,
inbox_id: @inbox.id,
source_id: @source_id
source_id: source_id
)
end
end

View file

@ -1,40 +0,0 @@
class ConversationBuilder
pattr_initialize [:params!, :contact_inbox!]
def perform
look_up_exising_conversation || create_new_conversation
end
private
def look_up_exising_conversation
return unless @contact_inbox.inbox.lock_to_single_conversation?
@contact_inbox.conversations.last
end
def create_new_conversation
::Conversation.create!(conversation_params)
end
def conversation_params
additional_attributes = params[:additional_attributes]&.permit! || {}
custom_attributes = params[:custom_attributes]&.permit! || {}
status = params[:status].present? ? { status: params[:status] } : {}
# TODO: temporary fallback for the old bot status in conversation, we will remove after couple of releases
# commenting this out to see if there are any errors, if not we can remove this in subsequent releases
# status = { status: 'pending' } if status[:status] == 'bot'
{
account_id: @contact_inbox.inbox.account_id,
inbox_id: @contact_inbox.inbox_id,
contact_id: @contact_inbox.contact_id,
contact_inbox_id: @contact_inbox.id,
additional_attributes: additional_attributes,
custom_attributes: custom_attributes,
snoozed_until: params[:snoozed_until],
assignee_id: params[:assignee_id],
team_id: params[:team_id]
}.merge(status)
end
end

View file

@ -22,9 +22,10 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
return if @inbox.channel.reauthorization_required?
ActiveRecord::Base.transaction do
build_contact_inbox
build_contact
build_message
end
ensure_contact_avatar
rescue Koala::Facebook::AuthenticationError
@inbox.channel.authorization_error!
rescue StandardError => e
@ -34,12 +35,15 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
private
def build_contact_inbox
@contact_inbox = ::ContactInboxWithContactBuilder.new(
source_id: @sender_id,
inbox: @inbox,
contact_attributes: contact_params
).perform
def contact
@contact ||= @inbox.contact_inboxes.find_by(source_id: @sender_id)&.contact
end
def build_contact
return if contact.present?
@contact = Contact.create!(contact_params.except(:remote_avatar_url))
@contact_inbox = ContactInbox.find_or_create_by!(contact: contact, inbox: @inbox, source_id: @sender_id)
end
def build_message
@ -50,11 +54,19 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
end
end
def ensure_contact_avatar
return if contact_params[:remote_avatar_url].blank?
return if @contact.avatar.attached?
Avatar::AvatarFromUrlJob.perform_later(@contact, contact_params[:remote_avatar_url])
end
def conversation
@conversation ||= Conversation.find_by(conversation_params) || build_conversation
end
def build_conversation
@contact_inbox ||= contact.contact_inboxes.find_by!(source_id: @sender_id)
Conversation.create!(conversation_params.merge(
contact_inbox_id: @contact_inbox.id
))
@ -82,7 +94,7 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
{
account_id: @inbox.account_id,
inbox_id: @inbox.id,
contact_id: @contact_inbox.contact_id
contact_id: contact.id
}
end
@ -93,7 +105,7 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
message_type: @message_type,
content: response.content,
source_id: response.identifier,
sender: @outgoing_echo ? nil : @contact_inbox.contact
sender: @outgoing_echo ? nil : contact
}
end
@ -101,7 +113,7 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
{
name: "#{result['first_name'] || 'John'} #{result['last_name'] || 'Doe'}",
account_id: @inbox.account_id,
avatar_url: result['profile_pic']
remote_avatar_url: result['profile_pic'] || ''
}
end

View file

@ -72,7 +72,6 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
def build_message
return if @outgoing_echo && already_sent_from_chatwoot?
return if message_content.blank? && all_unsupported_files?
@message = conversation.messages.create!(message_params)
@ -118,13 +117,6 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
cw_message.present?
end
def all_unsupported_files?
return if attachments.empty?
attachments_type = attachments.pluck(:type).uniq.first
unsupported_file_type?(attachments_type)
end
### Sample response
# {
# "object": "instagram",

View file

@ -35,13 +35,7 @@ class Messages::MessageBuilder
file: uploaded_attachment
)
attachment.file_type = if uploaded_attachment.is_a?(String)
file_type_by_signed_id(
uploaded_attachment
)
else
file_type(uploaded_attachment&.content_type)
end
attachment.file_type = file_type(uploaded_attachment&.content_type) if uploaded_attachment.is_a?(ActionDispatch::Http::UploadedFile)
end
end

View file

@ -2,8 +2,7 @@ class Messages::Messenger::MessageBuilder
include ::FileTypeHelper
def process_attachment(attachment)
# This check handles very rare case if there are multiple files to attach with only one usupported file
return if unsupported_file_type?(attachment['type'])
return if attachment['type'].to_sym == :template
attachment_obj = @message.attachments.new(attachment_params(attachment).except(:remote_file_url))
attachment_obj.save!
@ -46,7 +45,6 @@ class Messages::Messenger::MessageBuilder
end
def update_attachment_file_type(attachment)
return if @message.reload.attachments.blank?
return unless attachment.file_type == 'share' || attachment.file_type == 'story_mention'
attachment.file_type = file_type(attachment.file&.content_type)
@ -63,7 +61,6 @@ class Messages::Messenger::MessageBuilder
story_sender = result['from']['username']
message.content_attributes[:story_sender] = story_sender
message.content_attributes[:story_id] = story_id
message.content_attributes[:image_type] = 'story_mention'
message.content = I18n.t('conversations.messages.instagram_story_content', story_sender: story_sender)
message.save!
end
@ -76,7 +73,6 @@ class Messages::Messenger::MessageBuilder
raise
rescue Koala::Facebook::ClientError => e
# The exception occurs when we are trying fetch the deleted story or blocked story.
@message.attachments.destroy_all
@message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content'))
Rails.logger.error e
{}
@ -84,10 +80,4 @@ class Messages::Messenger::MessageBuilder
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
{}
end
private
def unsupported_file_type?(attachment_type)
[:template, :unsupported_type].include? attachment_type.to_sym
end
end

View file

@ -25,7 +25,7 @@ class NotificationSubscriptionBuilder
end
def build_identifier_subscription
@identifier_subscription = user.notification_subscriptions.create!(params.merge(identifier: identifier))
@identifier_subscription = user.notification_subscriptions.create(params.merge(identifier: identifier))
end
def update_identifier_subscription

View file

@ -5,10 +5,9 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
before_action :set_current_page, only: [:index]
def index
@portal_articles = @portal.articles
@all_articles = @portal_articles.search(list_params)
@articles_count = @all_articles.count
@articles = @all_articles.page(@current_page)
@articles_count = @portal.articles.count
@articles = @portal.articles
@articles = @articles.search(list_params) if list_params.present?
end
def create
@ -38,12 +37,12 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
end
def portal
@portal ||= Current.account.portals.find_by!(slug: params[:portal_id])
@portal ||= Current.account.portals.find_by(slug: params[:portal_id])
end
def article_params
params.require(:article).permit(
:title, :slug, :content, :description, :position, :category_id, :author_id, :associated_article_id, :status, meta: [:title, :description,
:title, :content, :description, :position, :category_id, :author_id, :associated_article_id, :status, meta: [:title, :description,
{ tags: [] }]
)
end

View file

@ -49,7 +49,7 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
def clone
automation_rule = Current.account.automation_rules.find_by(id: params[:automation_rule_id])
new_rule = automation_rule.dup
new_rule.save!
new_rule.save
@automation_rule = new_rule
end

View file

@ -5,7 +5,6 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
before_action :set_current_page, only: [:index]
def index
@current_locale = params[:locale]
@categories = @portal.categories.search(params)
end

View file

@ -44,7 +44,7 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
phone_number: phone_number,
medium: medium
)
@inbox = Current.account.inboxes.create!(
@inbox = Current.account.inboxes.create(
name: permitted_params[:name],
channel: @twilio_channel
)

View file

@ -2,11 +2,8 @@ class Api::V1::Accounts::Contacts::ContactInboxesController < Api::V1::Accounts:
before_action :ensure_inbox, only: [:create]
def create
@contact_inbox = ContactInboxBuilder.new(
contact: @contact,
inbox: @inbox,
source_id: params[:source_id]
).perform
source_id = params[:source_id] || SecureRandom.uuid
@contact_inbox = ContactInbox.create!(contact: @contact, inbox: @inbox, source_id: source_id)
end
private

View file

@ -2,7 +2,7 @@ class Api::V1::Accounts::Contacts::ConversationsController < Api::V1::Accounts::
def index
@conversations = Current.account.conversations.includes(
:assignee, :contact, :inbox, :taggings
).where(inbox_id: inbox_ids, contact_id: @contact.id).order(id: :desc).limit(20)
).where(inbox_id: inbox_ids, contact_id: @contact.id)
end
private

View file

@ -134,11 +134,8 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
return if params[:inbox_id].blank?
inbox = Current.account.inboxes.find(params[:inbox_id])
ContactInboxBuilder.new(
contact: @contact,
inbox: inbox,
source_id: params[:source_id]
).perform
source_id = params[:source_id] || SecureRandom.uuid
ContactInbox.create(contact: @contact, inbox: inbox, source_id: source_id)
end
def permitted_params

View file

@ -3,7 +3,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
include DateRangeHelper
before_action :conversation, except: [:index, :meta, :search, :create, :filter]
before_action :inbox, :contact, :contact_inbox, only: [:create]
before_action :contact_inbox, only: [:create]
def index
result = conversation_finder.perform
@ -24,7 +24,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def create
ActiveRecord::Base.transaction do
@conversation = ConversationBuilder.new(params: params, contact_inbox: @contact_inbox).perform
@conversation = ::Conversation.create!(conversation_params)
Messages::MessageBuilder.new(Current.user, @conversation, params[:message]).perform if params[:message].present?
end
end
@ -75,13 +75,10 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end
def update_last_seen
update_last_seen_on_conversation(DateTime.now.utc, assignee?)
end
def unread
last_incoming_message = @conversation.messages.incoming.last
last_seen_at = last_incoming_message.created_at - 1.second if last_incoming_message.present?
update_last_seen_on_conversation(last_seen_at, true)
# rubocop:disable Rails/SkipsModelValidations
@conversation.update_column(:agent_last_seen_at, DateTime.now.utc)
@conversation.update_column(:assignee_last_seen_at, DateTime.now.utc) if assignee?
# rubocop:enable Rails/SkipsModelValidations
end
def custom_attributes
@ -91,18 +88,9 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
private
def update_last_seen_on_conversation(last_seen_at, update_assignee)
# rubocop:disable Rails/SkipsModelValidations
@conversation.update_column(:agent_last_seen_at, last_seen_at)
@conversation.update_column(:assignee_last_seen_at, last_seen_at) if update_assignee.present?
# rubocop:enable Rails/SkipsModelValidations
end
def set_conversation_status
# TODO: temporary fallback for the old bot status in conversation, we will remove after couple of releases
# commenting this out to see if there are any errors, if not we can remove this in subsequent releases
# status = params[:status] == 'bot' ? 'pending' : params[:status]
@conversation.status = params[:status]
status = params[:status] == 'bot' ? 'pending' : params[:status]
@conversation.status = status
@conversation.snoozed_until = parse_date_time(params[:snoozed_until].to_s) if params[:snoozed_until]
end
@ -121,44 +109,51 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
authorize @conversation.inbox, :show?
end
def inbox
return if params[:inbox_id].blank?
@inbox = Current.account.inboxes.find(params[:inbox_id])
authorize @inbox, :show?
end
def contact
return if params[:contact_id].blank?
@contact = Current.account.contacts.find(params[:contact_id])
end
def contact_inbox
@contact_inbox = build_contact_inbox
# fallback for the old case where we do look up only using source id
# In future we need to change this and make sure we do look up on combination of inbox_id and source_id
# and deprecate the support of passing only source_id as the param
@contact_inbox ||= ::ContactInbox.find_by!(source_id: params[:source_id])
authorize @contact_inbox.inbox, :show?
end
def build_contact_inbox
return if @inbox.blank? || @contact.blank?
return if params[:contact_id].blank? || params[:inbox_id].blank?
inbox = Current.account.inboxes.find(params[:inbox_id])
authorize inbox, :show?
ContactInboxBuilder.new(
contact: @contact,
inbox: @inbox,
contact_id: params[:contact_id],
inbox_id: inbox.id,
source_id: params[:source_id]
).perform
end
def conversation_params
additional_attributes = params[:additional_attributes]&.permit! || {}
custom_attributes = params[:custom_attributes]&.permit! || {}
status = params[:status].present? ? { status: params[:status] } : {}
# TODO: temporary fallback for the old bot status in conversation, we will remove after couple of releases
status = { status: 'pending' } if status[:status] == 'bot'
{
account_id: Current.account.id,
inbox_id: @contact_inbox.inbox_id,
contact_id: @contact_inbox.contact_id,
contact_inbox_id: @contact_inbox.id,
additional_attributes: additional_attributes,
custom_attributes: custom_attributes,
snoozed_until: params[:snoozed_until],
assignee_id: params[:assignee_id],
team_id: params[:team_id]
}.merge(status)
end
def conversation_finder
@conversation_finder ||= ConversationFinder.new(Current.user, params)
@conversation_finder ||= ConversationFinder.new(current_user, params)
end
def assignee?
@conversation.assignee_id? && Current.user == @conversation.assignee
@conversation.assignee_id? && current_user == @conversation.assignee
end
end

View file

@ -22,7 +22,7 @@ class Api::V1::Accounts::CsatSurveyResponsesController < Api::V1::Accounts::Base
def download
response.headers['Content-Type'] = 'text/csv'
response.headers['Content-Disposition'] = 'attachment; filename=csat_report.csv'
render layout: false, template: 'api/v1/accounts/csat_survey_responses/download', formats: [:csv]
render layout: false, template: 'api/v1/accounts/csat_survey_responses/download.csv.erb', format: 'csv'
end
private

View file

@ -113,8 +113,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
def inbox_attributes
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
:lock_to_single_conversation]
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved]
end
def permitted_params(channel_attributes = [])

View file

@ -1,6 +1,6 @@
class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
before_action :check_authorization
before_action :fetch_macro, only: [:show, :update, :destroy, :execute]
before_action :check_authorization, only: [:show, :update, :destroy, :execute]
def index
@macros = Macro.with_visibility(current_user, params)
@ -14,34 +14,19 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
render json: { error: @macro.errors.messages }, status: :unprocessable_entity and return unless @macro.valid?
@macro.save!
process_attachments
@macro
end
def show
head :not_found if @macro.nil?
end
def show; end
def destroy
@macro.destroy!
head :ok
end
def attach_file
file_blob = ActiveStorage::Blob.create_and_upload!(
key: nil,
io: params[:attachment].tempfile,
filename: params[:attachment].original_filename,
content_type: params[:attachment].content_type
)
render json: { blob_key: file_blob.key, blob_id: file_blob.id }
end
def update
ActiveRecord::Base.transaction do
@macro.update!(macros_with_user)
@macro.set_visibility(current_user, permitted_params)
process_attachments
@macro.save!
rescue StandardError => e
Rails.logger.error e
@ -55,19 +40,6 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
head :ok
end
private
def process_attachments
actions = @macro.actions.filter_map { |k, _v| k if k['action_name'] == 'send_attachment' }
return if actions.blank?
actions.each do |action|
blob_id = action['action_params']
blob = ActiveStorage::Blob.find_by(id: blob_id)
@macro.files.attach(blob)
end
end
def permitted_params
params.permit(
:name, :account_id, :visibility,
@ -82,8 +54,4 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
def fetch_macro
@macro = Current.account.macros.find_by(id: params[:id])
end
def check_authorization
authorize(@macro) if @macro.present?
end
end

View file

@ -14,14 +14,10 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
@portal.members << agents
end
def show
@all_articles = @portal.articles
@articles = @all_articles.search(locale: params[:locale])
end
def show; end
def create
@portal = Current.account.portals.build(portal_params)
@portal.custom_domain = parsed_custom_domain
@portal.save!
process_attached_logo
end
@ -29,7 +25,6 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
def update
ActiveRecord::Base.transaction do
@portal.update!(portal_params) if params[:portal].present?
# @portal.custom_domain = parsed_custom_domain
process_attached_logo
rescue StandardError => e
Rails.logger.error e
@ -75,9 +70,4 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
def set_current_page
@current_page = params[:page] || 1
end
def parsed_custom_domain
domain = URI.parse(@portal.custom_domain)
domain.is_a?(URI::HTTP) ? domain.host : @portal.custom_domain
end
end

View file

@ -24,7 +24,7 @@ class Api::V1::AccountsController < Api::BaseController
).perform
if @user
send_auth_headers(@user)
render 'api/v1/accounts/create', format: :json, locals: { resource: @user }
render 'api/v1/accounts/create.json', locals: { resource: @user }
else
render_error_response(CustomExceptions::Account::SignupFailed.new({}))
end
@ -32,7 +32,7 @@ class Api::V1::AccountsController < Api::BaseController
def show
@latest_chatwoot_version = ::Redis::Alfred.get(::Redis::Alfred::LATEST_CHATWOOT_VERSION)
render 'api/v1/accounts/show', format: :json
render 'api/v1/accounts/show.json'
end
def update

View file

@ -9,7 +9,7 @@ class Api::V1::NotificationSubscriptionsController < Api::BaseController
def destroy
notification_subscription = NotificationSubscription.where(["subscription_attributes->>'push_token' = ?", params[:push_token]]).first
notification_subscription.destroy! if notification_subscription.present?
notification_subscription.destroy!
head :ok
end

View file

@ -18,19 +18,10 @@ class Api::V1::ProfilesController < Api::BaseController
head :ok
end
def auto_offline
@user.account_users.find_by!(account_id: auto_offline_params[:account_id]).update!(auto_offline: auto_offline_params[:auto_offline] || false)
end
def availability
@user.account_users.find_by!(account_id: availability_params[:account_id]).update!(availability: availability_params[:availability])
end
def set_active_account
@user.account_users.find_by(account_id: profile_params[:account_id]).update(active_at: Time.now.utc)
head :ok
end
private
def set_user
@ -41,10 +32,6 @@ class Api::V1::ProfilesController < Api::BaseController
params.require(:profile).permit(:account_id, :availability)
end
def auto_offline_params
params.require(:profile).permit(:account_id, :auto_offline)
end
def profile_params
params.require(:profile).permit(
:email,
@ -52,7 +39,6 @@ class Api::V1::ProfilesController < Api::BaseController
:display_name,
:avatar,
:message_signature,
:account_id,
ui_settings: {}
)
end

View file

@ -50,9 +50,7 @@ class Api::V1::Widget::BaseController < ApplicationController
end
def contact_name
return if @contact.email.present? || @contact.phone_number.present? || @contact.identifier.present?
permitted_params.dig(:contact, :name) || (contact_email.split('@')[0] if contact_email.present?)
params[:contact][:name] || contact_email.split('@')[0] if contact_email.present?
end
def contact_phone_number

View file

@ -9,7 +9,7 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
ActiveRecord::Base.transaction do
process_update_contact
@conversation = create_conversation
conversation.messages.create!(message_params)
conversation.messages.create(message_params)
end
end
@ -17,8 +17,7 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
@contact = ContactIdentifyAction.new(
contact: @contact,
params: { email: contact_email, phone_number: contact_phone_number, name: contact_name },
retain_original_contact_name: true,
discard_invalid_attrs: true
retain_original_contact_name: true
).perform
end
@ -60,7 +59,7 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
unless conversation.resolved?
conversation.status = :resolved
conversation.save!
conversation.save
end
head :ok
end

View file

@ -14,22 +14,22 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
def agents
@report_data = generate_agents_report
generate_csv('agents_report', 'api/v2/accounts/reports/agents')
generate_csv('agents_report', 'api/v2/accounts/reports/agents.csv.erb')
end
def inboxes
@report_data = generate_inboxes_report
generate_csv('inboxes_report', 'api/v2/accounts/reports/inboxes')
generate_csv('inboxes_report', 'api/v2/accounts/reports/inboxes.csv.erb')
end
def labels
@report_data = generate_labels_report
generate_csv('labels_report', 'api/v2/accounts/reports/labels')
generate_csv('labels_report', 'api/v2/accounts/reports/labels.csv.erb')
end
def teams
@report_data = generate_teams_report
generate_csv('teams_report', 'api/v2/accounts/reports/teams')
generate_csv('teams_report', 'api/v2/accounts/reports/teams.csv.erb')
end
def conversations
@ -43,7 +43,7 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
def generate_csv(filename, template)
response.headers['Content-Type'] = 'text/csv'
response.headers['Content-Disposition'] = "attachment; filename=#{filename}.csv"
render layout: false, template: template, formats: [:csv]
render layout: false, template: template, format: 'csv'
end
def check_authorization

View file

@ -13,8 +13,6 @@ module RequestExceptionHandler
render_not_found_error('Resource could not be found')
rescue Pundit::NotAuthorizedError
render_unauthorized('You are not authorized to do this action')
rescue ActionController::ParameterMissing => e
render_could_not_create_error(e.message)
ensure
# to address the thread variable leak issues in Puma/Thin webserver
Current.reset

View file

@ -4,7 +4,8 @@ class DashboardController < ActionController::Base
before_action :set_global_config
around_action :switch_locale
before_action :ensure_installation_onboarding, only: [:index]
before_action :render_hc_if_custom_domain, only: [:index]
before_action :redirect_to_custom_domain_page
before_action :redirect_to_saml_login
layout 'vueapp'
@ -16,7 +17,8 @@ class DashboardController < ActionController::Base
@global_config = GlobalConfig.get(
'LOGO', 'LOGO_THUMBNAIL',
'INSTALLATION_NAME',
'WIDGET_BRAND_URL', 'TERMS_URL',
'WIDGET_BRAND_URL',
'TERMS_URL',
'PRIVACY_URL',
'DISPLAY_MANIFEST',
'CREATE_NEW_ACCOUNT_FROM_DASHBOARD',
@ -24,12 +26,12 @@ class DashboardController < ActionController::Base
'API_CHANNEL_NAME',
'API_CHANNEL_THUMBNAIL',
'ANALYTICS_TOKEN',
'ANALYTICS_HOST',
'DIRECT_UPLOADS_ENABLED',
'HCAPTCHA_SITE_KEY',
'LOGOUT_REDIRECT_LINK',
'DISABLE_USER_PROFILE_UPDATE',
'DEPLOYMENT_ENV',
'CSML_EDITOR_HOST'
'DEPLOYMENT_ENV'
).merge(app_config)
end
@ -37,15 +39,17 @@ class DashboardController < ActionController::Base
redirect_to '/installation/onboarding' if ::Redis::Alfred.get(::Redis::Alfred::CHATWOOT_INSTALLATION_ONBOARDING)
end
def render_hc_if_custom_domain
domain = request.host
return if domain == URI.parse(ENV.fetch('FRONTEND_URL', '')).host
def redirect_to_custom_domain_page
custom_domain = request.host
portal = Portal.find_by(custom_domain: custom_domain)
@portal = Portal.find_by(custom_domain: domain)
return unless @portal
return unless portal
@locale = @portal.default_locale
render 'public/api/v1/portals/show', layout: 'portal', portal: @portal and return
redirect_to "/hc/#{portal.slug}"
end
def redirect_to_saml_login
redirect_to '/saml' and return unless current_user
end
def app_config
@ -58,4 +62,29 @@ class DashboardController < ActionController::Base
IS_ENTERPRISE: ChatwootApp.enterprise?
}
end
def saml_settings
settings = OneLogin::RubySaml::Settings.new
settings.assertion_consumer_service_url = "http://#{request.host}/saml/consume"
settings.sp_entity_id = "http://#{request.host}/saml/metadata"
settings.idp_entity_id = 'https://app.onelogin.com/saml/metadata/1835014'
settings.idp_sso_target_url = 'https://app.onelogin.com/trust/saml2/http-post/sso/1835014'
settings.idp_slo_target_url = 'https://app.onelogin.com/trust/saml2/http-redirect/slo/1835014'
settings.name_identifier_format = 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'
# Optional for most SAML IdPs
settings.authn_context = 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport'
# or as an array
settings.authn_context = [
'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport',
'urn:oasis:names:tc:SAML:2.0:ac:classes:Password'
]
# Optional bindings (defaults to Redirect for logout POST for ACS)
settings.single_logout_service_binding = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect' # or :post, :redirect
settings.assertion_consumer_service_binding = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST' # or :post, :redirect
settings
end
end

View file

@ -14,7 +14,7 @@ class DeviseOverrides::ConfirmationsController < Devise::ConfirmationsController
def render_confirmation_success
send_auth_headers(@confirmable)
render partial: 'devise/auth', formats: [:json], locals: { resource: @confirmable }
render partial: 'devise/auth.json', locals: { resource: @confirmable }
end
def render_confirmation_error

View file

@ -11,7 +11,7 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController
@recoverable = User.find_by(reset_password_token: reset_password_token)
if @recoverable && reset_password_and_confirmation(@recoverable)
send_auth_headers(@recoverable)
render partial: 'devise/auth', formats: [:json], locals: { resource: @recoverable }
render partial: 'devise/auth.json', locals: { resource: @recoverable }
else
render json: { message: 'Invalid token', redirect_url: '/' }, status: :unprocessable_entity
end

View file

@ -16,14 +16,14 @@ class DeviseOverrides::SessionsController < ::DeviseTokenAuth::SessionsControlle
end
def render_create_success
render partial: 'devise/auth', formats: [:json], locals: { resource: @resource }
render partial: 'devise/auth.json', locals: { resource: @resource }
end
private
def authenticate_resource_with_sso_token
@token = @resource.create_token
@resource.save!
@resource.save
sign_in(:user, @resource, store: false, bypass: false)
# invalidate the token after the user is signed in

View file

@ -2,7 +2,7 @@ class DeviseOverrides::TokenValidationsController < ::DeviseTokenAuth::TokenVali
def validate_token
# @resource will have been set by set_user_by_token concern
if @resource
render 'devise/token', formats: [:json]
render 'devise/token.json'
else
render_validate_token_error
end

View file

@ -1,16 +1,18 @@
class Platform::Api::V1::AccountsController < PlatformController
def create
@resource = Account.create!(account_params)
update_resource_features
@resource = Account.new(account_params)
@resource.save!
@platform_app.platform_app_permissibles.find_or_create_by(permissible: @resource)
render json: @resource
end
def show; end
def show
render json: @resource
end
def update
@resource.assign_attributes(account_params)
update_resource_features
@resource.save!
@resource.update!(account_params)
render json: @resource
end
def destroy
@ -25,18 +27,6 @@ class Platform::Api::V1::AccountsController < PlatformController
end
def account_params
permitted_params.except(:features)
end
def update_resource_features
return if permitted_params[:features].blank?
permitted_params[:features].each do |key, value|
value.present? ? @resource.enable_features(key) : @resource.disable_features(key)
end
end
def permitted_params
params.permit(:name, :locale, :domain, :support_email, :status, features: {}, limits: {}, custom_attributes: {})
params.permit(:name, :locale)
end
end

View file

@ -51,6 +51,6 @@ class Platform::Api::V1::UsersController < PlatformController
end
def user_params
params.permit(:name, :display_name, :email, :password, custom_attributes: {})
params.permit(:name, :email, :password, custom_attributes: {})
end
end

View file

@ -4,10 +4,10 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon
def create
source_id = params[:source_id] || SecureRandom.uuid
@contact_inbox = ::ContactInboxWithContactBuilder.new(
@contact_inbox = ::ContactBuilder.new(
source_id: source_id,
inbox: @inbox_channel.inbox,
contact_attributes: permitted_params.except(:identifier_hash)
contact_attributes: permitted_params.except(:identifier, :identifier_hash)
).perform
end

View file

@ -3,15 +3,9 @@ class Public::Api::V1::InboxesController < PublicController
before_action :set_contact_inbox
before_action :set_conversation
def show
@inbox_channel = ::Channel::Api.find_by!(identifier: params[:id])
end
private
def set_inbox_channel
return if params[:inbox_id].blank?
@inbox_channel = ::Channel::Api.find_by!(identifier: params[:inbox_id])
end

View file

@ -1,9 +1,8 @@
class Public::Api::V1::Portals::ArticlesController < PublicController
before_action :ensure_custom_domain_request, only: [:show, :index]
before_action :portal
before_action :set_category, except: [:index]
before_action :set_portal
before_action :set_category
before_action :set_article, only: [:show]
layout 'portal'
def index
@articles = @portal.articles
@ -16,24 +15,17 @@ class Public::Api::V1::Portals::ArticlesController < PublicController
def set_article
@article = @category.articles.find(params[:id])
@parsed_content = render_article_content(@article.content)
end
def set_category
@category = @portal.categories.find_by!(slug: params[:category_slug]) if params[:category_slug].present?
@category = @portal.categories.find_by!(slug: params[:category_slug])
end
def portal
@portal ||= Portal.find_by!(slug: params[:slug], archived: false)
def set_portal
@portal = @portals.find_by!(slug: params[:slug], archived: false)
end
def list_params
params.permit(:query)
end
def render_article_content(content)
# rubocop:disable Rails/OutputSafety
CommonMarker.render_html(content).html_safe
# rubocop:enable Rails/OutputSafety
end
end

View file

@ -1,8 +1,7 @@
class Public::Api::V1::Portals::CategoriesController < PublicController
before_action :ensure_custom_domain_request, only: [:show, :index]
before_action :portal
before_action :set_portal
before_action :set_category, only: [:show]
layout 'portal'
def index
@categories = @portal.categories
@ -13,10 +12,10 @@ class Public::Api::V1::Portals::CategoriesController < PublicController
private
def set_category
@category = @portal.categories.find_by!(locale: params[:locale], slug: params[:category_slug])
@category = @portal.categories.find_by!(locale: params[:locale])
end
def portal
@portal ||= Portal.find_by!(slug: params[:slug], archived: false)
def set_portal
@portal = @portals.find_by!(slug: params[:slug], archived: false)
end
end

View file

@ -1,21 +1,12 @@
class Public::Api::V1::PortalsController < PublicController
before_action :ensure_custom_domain_request, only: [:show]
before_action :portal
before_action :redirect_to_portal_with_locale, only: [:show]
layout 'portal'
before_action :set_portal
def show; end
private
def portal
@portal ||= Portal.find_by!(slug: params[:slug], archived: false)
@locale = params[:locale] || @portal.default_locale
end
def redirect_to_portal_with_locale
return if params[:locale].present?
redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}"
def set_portal
@portal = @portals.find_by!(slug: params[:slug], archived: false)
end
end

View file

@ -7,15 +7,14 @@ class PublicController < ActionController::Base
private
def ensure_custom_domain_request
domain = request.host
custom_domain = request.host
return if [URI.parse(ENV.fetch('FRONTEND_URL', '')).host, URI.parse(ENV.fetch('HELPCENTER_URL', '')).host].include?(domain)
@portals = ::Portal.where(custom_domain: custom_domain)
@portal = ::Portal.find_by(custom_domain: domain)
return if @portal.present?
return if @portals.present?
render json: {
error: "Domain: #{domain} is not registered with us. \
error: "Domain: #{custom_domain} is not registered with us. \
Please send us an email at support@chatwoot.com with the custom domain name and account API key"
}, status: :unauthorized and return
end

View file

@ -0,0 +1,106 @@
# This controller expects you to use the URLs /saml/init and /saml/consume in your OneLogin application.
class SamlController < ApplicationController
# skip_before_action :verify_authenticity_token, :only => [:consume]
layout 'vueapp'
include SsoAuthenticatable
def index
request = OneLogin::RubySaml::Authrequest.new
redirect_to(request.create(saml_settings))
end
def consume
response = OneLogin::RubySaml::Response.new(params['SAMLResponse'])
response.settings = saml_settings
# We validate the SAML Response and check if the user already exists in the system
if response.is_valid?
# authorize_success, log the user
find_the_resource(response.nameid)
create_session_and_assign_token
encoded_email = ERB::Util.url_encode(@resource.email)
redirect_to "https://db7b-103-51-75-84.in.ngrok.io/app/login?email=#{encoded_email}&sso_auth_token=#{@resource.generate_sso_auth_token}"
else
Rails.logger.error "Response Invalid. Errors: #{response.errors}"
end
end
def metadata
settings = saml_settings
meta = OneLogin::RubySaml::Metadata.new
render xml: meta.generate(settings, true)
end
def logout
# If we're given a logout request, handle it in the IdP logout initiated method
idp_logout_request
end
# Method to handle IdP initiated logouts
def idp_logout_request
settings = saml_settings
logout_request = OneLogin::RubySaml::SloLogoutrequest.new(params[:SAMLRequest], settings: settings)
unless logout_request.is_valid?
error_msg = "IdP initiated LogoutRequest was not valid!. Errors: #{logout_request.errors}"
Rails.logger.error error_msg
render inline: error_msg
end
Rails.logger.info "IdP initiated Logout for #{logout_request.nameid}"
# Actually log out this session
reset_session
logout_response = OneLogin::RubySaml::SloLogoutresponse.new.create(settings, logout_request.id, nil, RelayState: params[:RelayState])
redirect_to logout_response
end
private
def find_the_resource(email)
@resource = User.find_by(email: email)
end
def create_session_and_assign_token
create_and_assign_token
sign_in(:user, @resource, store: true, bypass: false)
end
def create_and_assign_token
if @resource.respond_to?(:with_lock)
@resource.with_lock do
@token = @resource.create_token
@resource.save!
end
else
@token = @resource.create_token
@resource.save!
end
end
def render_create_success
render partial: 'devise/auth.json', locals: { resource: @resource }
end
def saml_settings
settings = OneLogin::RubySaml::Settings.new
settings.soft = true
settings.assertion_consumer_service_url = 'https://db7b-103-51-75-84.in.ngrok.io/saml/consume'
settings.sp_entity_id = 'https://db7b-103-51-75-84.in.ngrok.io/saml/metadata'
settings.idp_entity_id = 'https://app.onelogin.com/saml2'
settings.idp_sso_target_url = 'https://chatwoot-dev.onelogin.com/trust/saml2/http-post/sso/de789d10-0617-44e9-8fd6-9d798809cfbf'
settings.idp_slo_target_url = 'https://chatwoot-dev.onelogin.com/trust/saml2/http-redirect/slo/1861655'
settings.name_identifier_format = 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'
settings.idp_cert_fingerprint = 'FD:17:5E:81:F8:F5:88:EF:21:AB:94:44:3E:4A:C4:72:94:E2:63:AE'
settings.idp_cert_fingerprint_algorithm = 'http://www.w3.org/2000/09/xmldsig#sha1'
# Optional bindings (defaults to Redirect for logout POST for ACS)
settings.single_logout_service_binding = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect' # or :post, :redirect
settings.assertion_consumer_service_binding = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST' # or :post, :redirect
settings
end
end

View file

@ -36,7 +36,6 @@ class SuperAdmin::AccountsController < SuperAdmin::ApplicationController
def resource_params
permitted_params = super
permitted_params[:limits] = permitted_params[:limits].to_h.compact
permitted_params[:selected_feature_flags] = params[:enabled_features].keys.map(&:to_sym) if params[:enabled_features].present?
permitted_params
end

View file

@ -44,12 +44,12 @@ class Twitter::CallbacksController < Twitter::BaseController
end
def create_inbox
twitter_profile = account.twitter_profiles.create!(
twitter_profile = account.twitter_profiles.create(
twitter_access_token: parsed_body['oauth_token'],
twitter_access_token_secret: parsed_body['oauth_token_secret'],
profile_id: parsed_body['user_id']
)
account.inboxes.create!(
account.inboxes.create(
name: parsed_body['screen_name'],
channel: twitter_profile
)

View file

@ -8,15 +8,7 @@ class AccountDashboard < Administrate::BaseDashboard
# which determines how the attribute is displayed
# on pages throughout the dashboard.
enterprise_attribute_types = if ChatwootApp.enterprise?
{
limits: Enterprise::AccountLimitsField,
all_features: Enterprise::AccountFeaturesField
}
else
{}
end
enterprise_attribute_types = ChatwootApp.enterprise? ? { limits: Enterprise::AccountLimitsField } : {}
ATTRIBUTE_TYPES = {
id: Field::Number,
name: Field::String,
@ -45,7 +37,7 @@ class AccountDashboard < Administrate::BaseDashboard
# SHOW_PAGE_ATTRIBUTES
# an array of attributes that will be displayed on the model's show page.
enterprise_show_page_attributes = ChatwootApp.enterprise? ? %i[limits all_features] : []
enterprise_show_page_attributes = ChatwootApp.enterprise? ? %i[limits] : []
SHOW_PAGE_ATTRIBUTES = (%i[
id
name
@ -60,7 +52,7 @@ class AccountDashboard < Administrate::BaseDashboard
# FORM_ATTRIBUTES
# an array of attributes that will be displayed
# on the model's form (`new` and `edit`) pages.
enterprise_form_attributes = ChatwootApp.enterprise? ? %i[limits all_features] : []
enterprise_form_attributes = ChatwootApp.enterprise? ? %i[limits] : []
FORM_ATTRIBUTES = (%i[
name
locale

View file

@ -1,7 +0,0 @@
require 'administrate/field/base'
class Enterprise::AccountFeaturesField < Administrate::Field::Base
def to_s
data
end
end

View file

@ -56,6 +56,7 @@ class ConversationFinder
filter_by_team if @team
filter_by_labels if params[:labels]
filter_by_query if params[:q]
filter_by_reply_status
end
def set_inboxes
@ -75,9 +76,12 @@ class ConversationFinder
end
def find_all_conversations
if params[:conversation_type] == 'mention'
conversation_ids = current_account.mentions.where(user: current_user).pluck(:conversation_id)
@conversations = current_account.conversations.where(id: conversation_ids)
else
@conversations = current_account.conversations.where(inbox_id: @inbox_ids)
filter_by_conversation_type if params[:conversation_type]
@conversations
end
end
def filter_by_assignee_type
@ -92,15 +96,8 @@ class ConversationFinder
@conversations
end
def filter_by_conversation_type
case @params[:conversation_type]
when 'mention'
conversation_ids = current_account.mentions.where(user: current_user).pluck(:conversation_id)
@conversations = @conversations.where(id: conversation_ids)
when 'unattended'
@conversations = @conversations.where(first_reply_created_at: nil)
end
@conversations
def filter_by_reply_status
@conversations = @conversations.where(first_reply_created_at: nil) if params[:reply_status] == 'unattended'
end
def filter_by_query

View file

@ -21,9 +21,7 @@ class MessageFinder
end
def current_messages
if @params[:after].present?
messages.reorder('created_at asc').where('id >= ?', @params[:before].to_i).limit(20)
elsif @params[:before].present?
if @params[:before].present?
messages.reorder('created_at desc').where('id < ?', @params[:before].to_i).limit(20).reverse
else
messages.reorder('created_at desc').limit(20).reverse

View file

@ -8,12 +8,6 @@ module FileTypeHelper
:file
end
# Used in case of DIRECT_UPLOADS_ENABLED=true
def file_type_by_signed_id(signed_id)
blob = ActiveStorage::Blob.find_signed(signed_id)
file_type(blob&.content_type)
end
def image_file?(content_type)
[
'image/jpeg',

View file

@ -87,9 +87,6 @@ export default {
},
async initializeAccount() {
await this.$store.dispatch('accounts/get');
this.$store.dispatch('setActiveAccount', {
accountId: this.currentAccountId,
});
const {
locale,
latest_chatwoot_version: latestChatwootVersion,

View file

@ -1,9 +0,0 @@
import ApiClient from './ApiClient';
class AgentBotsAPI extends ApiClient {
constructor() {
super('agent_bots', { accountScoped: true });
}
}
export default new AgentBotsAPI();

View file

@ -144,22 +144,7 @@ export default {
});
},
updateAutoOffline(accountId, autoOffline = false) {
return axios.post(endPoints('autoOffline').url, {
profile: { account_id: accountId, auto_offline: autoOffline },
});
},
deleteAvatar() {
return axios.delete(endPoints('deleteAvatar').url);
},
setActiveAccount({ accountId }) {
const urlData = endPoints('setActiveAccount');
return axios.put(urlData.url, {
profile: {
account_id: accountId,
},
});
},
};

View file

@ -16,9 +16,6 @@ const endPoints = {
availabilityUpdate: {
url: '/api/v1/profile/availability',
},
autoOffline: {
url: '/api/v1/profile/auto_offline',
},
logout: {
url: 'auth/sign_out',
},
@ -43,10 +40,6 @@ const endPoints = {
deleteAvatar: {
url: '/api/v1/profile/avatar',
},
setActiveAccount: {
url: '/api/v1/profile/set_active_account',
},
};
export default page => {

View file

@ -7,8 +7,8 @@ class CategoriesAPI extends PortalsAPI {
super('categories', { accountScoped: true });
}
get({ portalSlug, locale }) {
return axios.get(`${this.url}/${portalSlug}/categories?locale=${locale}`);
get({ portalSlug }) {
return axios.get(`${this.url}/${portalSlug}/categories`);
}
create({ portalSlug, categoryObj }) {

View file

@ -6,10 +6,6 @@ class PortalsAPI extends ApiClient {
super('portals', { accountScoped: true });
}
getPortal({ portalSlug, locale }) {
return axios.get(`${this.url}/${portalSlug}?locale=${locale}`);
}
updatePortal({ portalSlug, portalObj }) {
return axios.patch(`${this.url}/${portalSlug}`, portalObj);
}

View file

@ -68,10 +68,6 @@ class ConversationApi extends ApiClient {
return axios.post(`${this.url}/${id}/update_last_seen`);
}
markMessagesUnread({ id }) {
return axios.post(`${this.url}/${id}/unread`);
}
toggleTyping({ conversationId, status, isPrivate }) {
return axios.post(`${this.url}/${conversationId}/toggle_typing_status`, {
typing_status: status,
@ -109,16 +105,6 @@ class ConversationApi extends ApiClient {
custom_attributes: customAttributes,
});
}
fetchParticipants(conversationId) {
return axios.get(`${this.url}/${conversationId}/participants`);
}
updateParticipants({ conversationId, userIds }) {
return axios.patch(`${this.url}/${conversationId}/participants`, {
user_ids: userIds,
});
}
}
export default new ConversationApi();

View file

@ -13,16 +13,6 @@ class Inboxes extends ApiClient {
deleteInboxAvatar(inboxId) {
return axios.delete(`${this.url}/${inboxId}/avatar`);
}
getAgentBot(inboxId) {
return axios.get(`${this.url}/${inboxId}/agent_bot`);
}
setAgentBot(inboxId, botId) {
return axios.post(`${this.url}/${inboxId}/set_agent_bot`, {
agent_bot: botId,
});
}
}
export default new Inboxes();

View file

@ -1,16 +0,0 @@
/* global axios */
import ApiClient from './ApiClient';
class MacrosAPI extends ApiClient {
constructor() {
super('macros', { accountScoped: true });
}
executeMacro({ macroId, conversationIds }) {
return axios.post(`${this.url}/${macroId}/execute`, {
conversation_ids: conversationIds,
});
}
}
export default new MacrosAPI();

View file

@ -1,13 +0,0 @@
import AgentBotsAPI from '../agentBots';
import ApiClient from '../ApiClient';
describe('#AgentBotsAPI', () => {
it('creates correct instance', () => {
expect(AgentBotsAPI).toBeInstanceOf(ApiClient);
expect(AgentBotsAPI).toHaveProperty('get');
expect(AgentBotsAPI).toHaveProperty('show');
expect(AgentBotsAPI).toHaveProperty('create');
expect(AgentBotsAPI).toHaveProperty('update');
expect(AgentBotsAPI).toHaveProperty('delete');
});
});

View file

@ -11,8 +11,6 @@ describe('#InboxesAPI', () => {
expect(inboxesAPI).toHaveProperty('update');
expect(inboxesAPI).toHaveProperty('delete');
expect(inboxesAPI).toHaveProperty('getCampaigns');
expect(inboxesAPI).toHaveProperty('getAgentBot');
expect(inboxesAPI).toHaveProperty('setAgentBot');
});
describeWithAPIMock('API calls', context => {
it('#getCampaigns', () => {

View file

@ -1,14 +0,0 @@
import macros from '../macros';
import ApiClient from '../ApiClient';
describe('#macrosAPI', () => {
it('creates correct instance', () => {
expect(macros).toBeInstanceOf(ApiClient);
expect(macros).toHaveProperty('get');
expect(macros).toHaveProperty('create');
expect(macros).toHaveProperty('update');
expect(macros).toHaveProperty('delete');
expect(macros).toHaveProperty('show');
expect(macros.url).toBe('/api/v1/macros');
});
});

View file

@ -1,6 +0,0 @@
/* global axios */
import wootConstants from 'dashboard/constants';
export const getTestimonialContent = () => {
return axios.get(wootConstants.TESTIMONIAL_URL);
};

View file

@ -74,8 +74,8 @@ Tahoma,
Arial,
sans-serif;
$body-antialiased: true;
$global-margin: $space-small;
$global-padding: $space-micro;
$global-margin: $space-one;
$global-padding: $space-one;
$global-weight-normal: normal;
$global-weight-bold: bold;
$global-radius: 0;

View file

@ -20,24 +20,6 @@
@include foundation-everything($flex: true);
@include foundation-prototype-text-utilities;
@include foundation-prototype-text-transformation;
@include foundation-prototype-text-decoration;
@include foundation-prototype-font-styling;
@include foundation-prototype-list-style-type;
@include foundation-prototype-rounded;
@include foundation-prototype-bordered;
@include foundation-prototype-shadow;
@include foundation-prototype-separator;
@include foundation-prototype-overflow;
@include foundation-prototype-display;
@include foundation-prototype-position;
@include foundation-prototype-border-box;
@include foundation-prototype-border-none;
@include foundation-prototype-sizing;
@include foundation-prototype-spacing;
@import 'typography';
@import 'layout';
@import 'animations';

View file

@ -113,22 +113,9 @@ $default-button-height: 4.0rem;
}
&.clear {
color: var(--w-700);
&.secondary {
color: var(--s-700)
}
&.success {
color: var(--g-700)
}
&.alert {
color: var(--r-700)
}
&.warning {
color: var(--y-700)
color: var(--y-600);
}
&:hover {
@ -155,20 +142,10 @@ $default-button-height: 4.0rem;
// Sizes
&.tiny {
height: var(--space-medium);
.icon+.button__content {
padding-left: var(--space-micro);
}
}
&.small {
height: var(--space-large);
padding-bottom: var(--space-smaller);
padding-top: var(--space-smaller);
.icon+.button__content {
padding-left: var(--space-smaller);
}
}
&.large {
@ -198,10 +175,6 @@ $default-button-height: 4.0rem;
height: auto;
margin: 0;
padding: 0;
&:hover {
text-decoration: underline;
}
}
}

View file

@ -14,9 +14,15 @@
}
.modal--close {
border-radius: 50%;
color: $color-heading;
cursor: pointer;
font-size: $font-size-big;
line-height: $space-normal;
padding: $space-normal;
position: absolute;
right: $space-small;
top: $space-small;
right: $space-micro;
top: $space-micro;
&:hover {
background: $color-background;

View file

@ -59,8 +59,12 @@
.hamburger--menu {
cursor: pointer;
display: block;
display: none;
margin-right: $space-normal;
@media screen and (max-width: 1200px) {
display: block;
}
}
.header--icon {

View file

@ -102,7 +102,6 @@
@assign-agent="onAssignAgent"
@update-conversations="onUpdateConversations"
@assign-labels="onAssignLabels"
@assign-team="onAssignTeamsForBulk"
/>
<div
ref="activeConversation"
@ -126,7 +125,6 @@
@assign-label="onAssignLabels"
@update-conversation-status="toggleConversationStatus"
@context-menu-toggle="onContextMenuToggle"
@mark-as-unread="markAsUnread"
/>
<div v-if="chatListLoading" class="text-center">
@ -186,11 +184,6 @@ import {
hasPressedAltAndJKey,
hasPressedAltAndKKey,
} from 'shared/helpers/KeyboardHelpers';
import { conversationListPageURL } from '../helper/URLHelper';
import {
isOnMentionsView,
isOnUnattendedView,
} from '../store/modules/conversations/helpers/actionHelpers';
export default {
components: {
@ -339,15 +332,14 @@ export default {
status: this.activeStatus,
page: this.currentPage + 1,
labels: this.label ? [this.label] : undefined,
teamId: this.teamId || undefined,
conversationType: this.conversationType || undefined,
teamId: this.teamId ? this.teamId : undefined,
conversationType: this.conversationType
? this.conversationType
: undefined,
folders: this.hasActiveFolders ? this.savedFoldersValue : undefined,
};
},
pageTitle() {
if (this.hasAppliedFilters) {
return this.$t('CHAT_LIST.TAB_HEADING');
}
if (this.inbox.name) {
return this.inbox.name;
}
@ -360,9 +352,6 @@ export default {
if (this.conversationType === 'mention') {
return this.$t('CHAT_LIST.MENTION_HEADING');
}
if (this.conversationType === 'unattended') {
return this.$t('CHAT_LIST.UNATTENDED_HEADING');
}
if (this.hasActiveFolders) {
return this.activeFolder.name;
}
@ -442,6 +431,9 @@ export default {
},
methods: {
onApplyFilter(payload) {
if (this.$route.name !== 'home') {
this.$router.push({ name: 'home' });
}
this.resetBulkActions();
this.foldersQuery = filterQueryGenerator(payload);
this.$store.dispatch('conversationPage/reset');
@ -644,35 +636,6 @@ export default {
this.showAlert(this.$t('BULK_ACTION.ASSIGN_FAILED'));
}
},
async markAsUnread(conversationId) {
try {
await this.$store.dispatch('markMessagesUnread', {
id: conversationId,
});
const {
params: { accountId, inbox_id: inboxId, label, teamId },
name,
} = this.$route;
let conversationType = '';
if (isOnMentionsView({ route: { name } })) {
conversationType = 'mention';
} else if (isOnUnattendedView({ route: { name } })) {
conversationType = 'unattended';
}
this.$router.push(
conversationListPageURL({
accountId,
conversationType: conversationType,
customViewId: this.foldersId,
inboxId,
label,
teamId,
})
);
} catch (error) {
// Ignore error
}
},
async onAssignTeam(team, conversationId = null) {
try {
await this.$store.dispatch('assignTeam', {
@ -722,21 +685,6 @@ export default {
this.showAlert(this.$t('BULK_ACTION.LABELS.ASSIGN_FAILED'));
}
},
async onAssignTeamsForBulk(team) {
try {
await this.$store.dispatch('bulkActions/process', {
type: 'Conversation',
ids: this.selectedConversations,
fields: {
team_id: team.id,
},
});
this.selectedConversations = [];
this.showAlert(this.$t('BULK_ACTION.TEAMS.ASSIGN_SUCCESFUL'));
} catch (err) {
this.showAlert(this.$t('BULK_ACTION.TEAMS.ASSIGN_FAILED'));
}
},
async onUpdateConversations(status) {
try {
await this.$store.dispatch('bulkActions/process', {

View file

@ -7,13 +7,9 @@
@click="onBackDropClick"
>
<div :class="modalContainerClassName" @click.stop>
<woot-button
color-scheme="secondary"
icon="dismiss"
variant="clear"
class="modal--close"
@click="close"
/>
<button class="modal--close" @click="close">
<fluent-icon icon="dismiss" />
</button>
<slot />
</div>
</div>

View file

@ -1,12 +1,7 @@
<template>
<woot-button
size="small"
variant="clear"
color-scheme="secondary"
icon="list"
class="toggle-sidebar"
@click="onMenuItemClick"
/>
<button @click="onMenuItemClick">
<fluent-icon class="hamburger--menu" icon="list" />
</button>
</template>
<script>
@ -21,8 +16,13 @@ export default {
};
</script>
<style scoped lang="scss">
.toggle-sidebar {
margin-right: var(--space-small);
margin-left: var(--space-minus-small);
.hamburger--menu {
cursor: pointer;
display: none;
margin-right: var(--space-normal);
@media screen and (max-width: 1200px) {
display: block;
}
}
</style>

View file

@ -5,12 +5,9 @@ import Button from './ui/WootButton';
import Code from './Code';
import ColorPicker from './widgets/ColorPicker';
import ConfirmDeleteModal from './widgets/modal/ConfirmDeleteModal.vue';
import ConfirmModal from './widgets/modal/ConfirmationModal.vue';
import ContextMenu from './ui/ContextMenu.vue';
import DeleteModal from './widgets/modal/DeleteModal.vue';
import DropdownItem from 'shared/components/ui/dropdown/DropdownItem';
import DropdownMenu from 'shared/components/ui/dropdown/DropdownMenu';
import FeatureToggle from './widgets/FeatureToggle';
import HorizontalBar from './widgets/chart/HorizontalBarChart';
import Input from './widgets/forms/Input.vue';
import Label from './ui/Label';
@ -24,6 +21,8 @@ import SubmitButton from './buttons/FormSubmitButton';
import Tabs from './ui/Tabs/Tabs';
import TabsItem from './ui/Tabs/TabsItem';
import Thumbnail from './widgets/Thumbnail.vue';
import ConfirmModal from './widgets/modal/ConfirmationModal.vue';
import ContextMenu from './ui/ContextMenu.vue';
const WootUIKit = {
AvatarUploader,
@ -32,12 +31,9 @@ const WootUIKit = {
Code,
ColorPicker,
ConfirmDeleteModal,
ConfirmModal,
ContextMenu,
DeleteModal,
DropdownItem,
DropdownMenu,
FeatureToggle,
HorizontalBar,
Input,
Label,
@ -51,6 +47,8 @@ const WootUIKit = {
Tabs,
TabsItem,
Thumbnail,
ConfirmModal,
ContextMenu,
install(Vue) {
const keys = Object.keys(this);
keys.pop(); // remove 'install' from keys

View file

@ -18,35 +18,12 @@
</woot-button>
</woot-dropdown-item>
<woot-dropdown-divider />
<woot-dropdown-item class="auto-offline--toggle">
<div class="info-wrap">
<fluent-icon
v-tooltip.right-start="$t('SIDEBAR.SET_AUTO_OFFLINE.INFO_TEXT')"
icon="info"
size="14"
class="info-icon"
/>
<span class="auto-offline--text">
{{ $t('SIDEBAR.SET_AUTO_OFFLINE.TEXT') }}
</span>
</div>
<woot-switch
size="small"
class="auto-offline--switch"
:value="currentUserAutoOffline"
@input="updateAutoOffline"
/>
</woot-dropdown-item>
<woot-dropdown-divider />
</woot-dropdown-menu>
</template>
<script>
import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway';
import alertMixin from 'shared/mixins/alertMixin';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu';
import WootDropdownHeader from 'shared/components/ui/dropdown/DropdownHeader';
@ -64,7 +41,7 @@ export default {
AvailabilityStatusBadge,
},
mixins: [clickaway, alertMixin],
mixins: [clickaway],
data() {
return {
@ -77,7 +54,6 @@ export default {
...mapGetters({
getCurrentUserAvailability: 'getCurrentUserAvailability',
currentAccountId: 'getCurrentAccountId',
currentUserAutoOffline: 'getCurrentUserAutoOffline',
}),
availabilityDisplayLabel() {
const availabilityIndex = AVAILABILITY_STATUS_KEYS.findIndex(
@ -109,30 +85,21 @@ export default {
closeStatusMenu() {
this.isStatusMenuOpened = false;
},
updateAutoOffline(autoOffline) {
this.$store.dispatch('updateAutoOffline', {
accountId: this.currentAccountId,
autoOffline,
});
},
changeAvailabilityStatus(availability) {
const accountId = this.currentAccountId;
if (this.isUpdating) {
return;
}
this.isUpdating = true;
try {
this.$store.dispatch('updateAvailability', {
availability,
account_id: this.currentAccountId,
});
} catch (error) {
this.showAlert(
this.$t('PROFILE_SETTINGS.FORM.AVAILABILITY.SET_AVAILABILITY_ERROR')
);
} finally {
this.$store
.dispatch('updateAvailability', {
availability: availability,
account_id: accountId,
})
.finally(() => {
this.isUpdating = false;
}
});
},
},
};
@ -176,32 +143,4 @@ export default {
align-items: baseline;
}
}
.auto-offline--toggle {
align-items: center;
display: flex;
justify-content: space-between;
padding: var(--space-smaller) 0 var(--space-smaller) var(--space-small);
margin: 0;
.info-wrap {
display: flex;
align-items: center;
}
.info-icon {
margin-top: -1px;
}
.auto-offline--switch {
margin: -1px var(--space-micro) 0;
}
.auto-offline--text {
margin: 0 var(--space-smaller);
font-size: var(--font-size-mini);
font-weight: var(--font-weight-medium);
color: var(--s-700);
}
}
</style>

View file

@ -73,14 +73,13 @@ export default {
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
currentRole: 'getCurrentRole',
currentUser: 'getCurrentUser',
globalConfig: 'globalConfig/get',
inboxes: 'inboxes/getInboxes',
isACustomBrandedInstance: 'globalConfig/isACustomBrandedInstance',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
inboxes: 'inboxes/getInboxes',
accountId: 'getCurrentAccountId',
currentRole: 'getCurrentRole',
labels: 'labels/getLabelsOnSidebar',
teams: 'teams/getMyTeams',
}),
@ -109,21 +108,9 @@ export default {
},
primaryMenuItems() {
const menuItems = this.sideMenuConfig.primaryMenu;
return menuItems.filter(menuItem => {
const isAvailableForTheUser = menuItem.roles.includes(this.currentRole);
if (!isAvailableForTheUser) {
return false;
}
if (menuItem.featureFlag) {
return this.isFeatureEnabledonAccount(
this.accountId,
menuItem.featureFlag
return menuItems.filter(menuItem =>
menuItem.roles.includes(this.currentRole)
);
}
return true;
});
},
activeSecondaryMenu() {
const { secondaryMenu } = this.sideMenuConfig;
@ -298,6 +285,8 @@ export default {
}
.secondary-menu .nested.vertical.menu {
overflow-y: auto;
height: 100%;
margin-left: var(--space-small);
}
</style>

View file

@ -16,8 +16,6 @@ const conversations = accountId => ({
'conversation_through_mentions',
'folder_conversations',
'conversations_through_folders',
'conversation_unattended',
'conversation_through_unattended',
],
menuItems: [
{
@ -35,13 +33,6 @@ const conversations = accountId => ({
toState: frontendURL(`accounts/${accountId}/mentions/conversations`),
toStateName: 'conversation_mentions',
},
{
icon: 'mail-unread',
label: 'UNATTENDED_CONVERSATIONS',
key: 'conversation_unattended',
toState: frontendURL(`accounts/${accountId}/unattended/conversations`),
toStateName: 'conversation_unattended',
},
],
});

View file

@ -1,4 +1,3 @@
import { FEATURE_FLAGS } from '../../../../featureFlags';
import { frontendURL } from '../../../../helper/URLHelper';
const primaryMenuItems = accountId => [
@ -14,7 +13,6 @@ const primaryMenuItems = accountId => [
icon: 'book-contacts',
key: 'contacts',
label: 'CONTACTS',
featureFlag: FEATURE_FLAGS.CRM,
toState: frontendURL(`accounts/${accountId}/contacts`),
toStateName: 'contacts_dashboard',
roles: ['administrator', 'agent'],
@ -23,7 +21,6 @@ const primaryMenuItems = accountId => [
icon: 'arrow-trending-lines',
key: 'reports',
label: 'REPORTS',
featureFlag: FEATURE_FLAGS.REPORTS,
toState: frontendURL(`accounts/${accountId}/reports`),
toStateName: 'settings_account_reports',
roles: ['administrator'],
@ -32,20 +29,10 @@ const primaryMenuItems = accountId => [
icon: 'megaphone',
key: 'campaigns',
label: 'CAMPAIGNS',
featureFlag: FEATURE_FLAGS.CAMPAIGNS,
toState: frontendURL(`accounts/${accountId}/campaigns`),
toStateName: 'settings_account_campaigns',
roles: ['administrator'],
},
{
icon: 'library',
key: 'helpcenter',
label: 'HELP_CENTER.TITLE',
featureFlag: FEATURE_FLAGS.HELP_CENTER,
toState: frontendURL(`accounts/${accountId}/portals`),
toStateName: 'default_portal_articles',
roles: ['administrator'],
},
{
icon: 'settings',
key: 'settings',

View file

@ -1,58 +1,45 @@
import { FEATURE_FLAGS } from '../../../../featureFlags';
import { frontendURL } from '../../../../helper/URLHelper';
const settings = accountId => ({
parentNav: 'settings',
routes: [
'agent_bots',
'agent_list',
'attributes_list',
'automation_list',
'billing_settings_index',
'canned_list',
'general_settings_index',
'general_settings',
'labels_list',
'macros_edit',
'macros_new',
'macros_wrapper',
'settings_applications_integration',
'settings_applications_webhook',
'settings_applications',
'settings_inbox_finish',
'settings_inbox_list',
'settings_inbox_new',
'settings_inbox_show',
'settings_inbox',
'settings_inboxes_add_agents',
'attributes_list',
'settings_inbox_new',
'settings_inbox_list',
'settings_inbox_show',
'settings_inboxes_page_channel',
'settings_integrations_dashboard_apps',
'settings_integrations_integration',
'settings_integrations_webhook',
'settings_inboxes_add_agents',
'settings_inbox_finish',
'settings_integrations',
'settings_teams_add_agents',
'settings_teams_edit_finish',
'settings_teams_edit_members',
'settings_teams_edit',
'settings_teams_finish',
'settings_integrations_webhook',
'settings_integrations_integration',
'settings_applications',
'settings_integrations_dashboard_apps',
'settings_applications_webhook',
'settings_applications_integration',
'general_settings',
'general_settings_index',
'settings_teams_list',
'settings_teams_new',
'settings_teams_add_agents',
'settings_teams_finish',
'settings_teams_edit',
'settings_teams_edit_members',
'settings_teams_edit_finish',
'billing_settings_index',
'automation_list',
],
menuItems: [
{
icon: 'briefcase',
label: 'ACCOUNT_SETTINGS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/general`),
toStateName: 'general_settings_index',
},
{
icon: 'people',
label: 'AGENTS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/agents/list`),
toStateName: 'agent_list',
featureFlag: FEATURE_FLAGS.AGENT_MANAGEMENT,
},
{
icon: 'people-team',
@ -60,7 +47,6 @@ const settings = accountId => ({
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/teams/list`),
toStateName: 'settings_teams_list',
featureFlag: FEATURE_FLAGS.TEAM_MANAGEMENT,
},
{
icon: 'mail-inbox-all',
@ -68,7 +54,6 @@ const settings = accountId => ({
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/inboxes/list`),
toStateName: 'settings_inbox_list',
featureFlag: FEATURE_FLAGS.INBOX_MANAGEMENT,
},
{
icon: 'tag',
@ -76,7 +61,6 @@ const settings = accountId => ({
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/labels/list`),
toStateName: 'labels_list',
featureFlag: FEATURE_FLAGS.LABELS,
},
{
icon: 'code',
@ -86,35 +70,13 @@ const settings = accountId => ({
`accounts/${accountId}/settings/custom-attributes/list`
),
toStateName: 'attributes_list',
featureFlag: FEATURE_FLAGS.CUSTOM_ATTRIBUTES,
},
{
icon: 'automation',
label: 'AUTOMATION',
beta: true,
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/automation/list`),
toStateName: 'automation_list',
featureFlag: FEATURE_FLAGS.AUTOMATIONS,
},
{
icon: 'bot',
label: 'AGENT_BOTS',
beta: true,
hasSubMenu: false,
globalConfigFlag: 'csmlEditorHost',
toState: frontendURL(`accounts/${accountId}/settings/agent-bots`),
toStateName: 'agent_bots',
featureFlag: FEATURE_FLAGS.AGENT_BOTS,
},
{
icon: 'flash-settings',
label: 'MACROS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/macros`),
toStateName: 'macros_wrapper',
beta: true,
featureFlag: FEATURE_FLAGS.MACROS,
},
{
icon: 'chat-multiple',
@ -124,7 +86,6 @@ const settings = accountId => ({
`accounts/${accountId}/settings/canned-response/list`
),
toStateName: 'canned_list',
featureFlag: FEATURE_FLAGS.CANNED_RESPONSES,
},
{
icon: 'flash-on',
@ -132,7 +93,6 @@ const settings = accountId => ({
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/integrations`),
toStateName: 'settings_integrations',
featureFlag: FEATURE_FLAGS.INTEGRATIONS,
},
{
icon: 'star-emphasis',
@ -140,7 +100,6 @@ const settings = accountId => ({
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/applications`),
toStateName: 'settings_applications',
featureFlag: FEATURE_FLAGS.INTEGRATIONS,
},
{
icon: 'credit-card-person',
@ -150,6 +109,13 @@ const settings = accountId => ({
toStateName: 'billing_settings_index',
showOnlyOnCloud: true,
},
{
icon: 'settings',
label: 'ACCOUNT_SETTINGS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/general`),
toStateName: 'general_settings_index',
},
],
});

View file

@ -8,33 +8,25 @@
:header-title="$t('SIDEBAR_ITEMS.CHANGE_ACCOUNTS')"
:header-content="$t('SIDEBAR_ITEMS.SELECTOR_SUBTITLE')"
/>
<div class="account-selector--wrap">
<div
v-for="account in currentUser.accounts"
:key="account.id"
class="account-selector"
>
<button
class="button expanded clear link"
@click="onChangeAccount(account.id)"
>
<span class="button__content">
<label :for="account.name" class="account-details--wrap">
<div class="account--name">{{ account.name }}</div>
<div class="account--role">{{ account.role }}</div>
</label>
</span>
<a :href="`/app/accounts/${account.id}/dashboard`">
<fluent-icon
v-show="account.id === accountId"
v-if="account.id === accountId"
class="selected--account"
icon="checkmark-circle"
type="solid"
size="24"
/>
</button>
<label :for="account.name" class="account--details">
<div class="account--name">{{ account.name }}</div>
<div class="account--role">{{ account.role }}</div>
</label>
</a>
</div>
</div>
<div
v-if="globalConfig.createNewAccountFromDashboard"
class="modal-footer delete-item"
@ -66,40 +58,5 @@ export default {
globalConfig: 'globalConfig/get',
}),
},
methods: {
onChangeAccount(accountId) {
const accountUrl = `/app/accounts/${accountId}/dashboard`;
window.location.href = accountUrl;
},
},
};
</script>
<style lang="scss" scoped>
.account-selector--wrap {
margin-top: var(--space-normal);
}
.account-selector {
padding-top: 0;
padding-bottom: 0;
.button {
display: flex;
justify-content: space-between;
padding: var(--space-one) var(--space-normal);
.account-details--wrap {
text-align: left;
.account--name {
cursor: pointer;
font-size: var(--font-size-medium);
font-weight: var(--font-weight-medium);
line-height: 1;
}
.account--role {
cursor: pointer;
font-size: var(--font-size-mini);
text-transform: capitalize;
}
}
}
}
</style>

View file

@ -61,24 +61,6 @@
</a>
</router-link>
</woot-dropdown-item>
<woot-dropdown-item v-if="currentUser.type === 'SuperAdmin'">
<a
href="/super_admin"
class="button small clear secondary"
target="_blank"
rel="noopener nofollow noreferrer"
@click="$emit('close')"
>
<fluent-icon
icon="content-settings"
size="14"
class="icon icon--font"
/>
<span class="button__content">
{{ $t('SIDEBAR_ITEMS.SUPER_ADMIN_CONSOLE') }}
</span>
</a>
</woot-dropdown-item>
<woot-dropdown-item>
<woot-button
variant="clear"
@ -153,7 +135,7 @@ export default {
.dropdown-pane {
left: var(--space-slab);
bottom: var(--space-larger);
min-width: 22rem;
z-index: var(--z-index-low);
min-width: 16.8rem;
z-index: var(--z-index-much-higher);
}
</style>

View file

@ -20,8 +20,6 @@
import { frontendURL } from '../../../helper/URLHelper';
import SecondaryNavItem from './SecondaryNavItem.vue';
import AccountContext from './AccountContext.vue';
import { mapGetters } from 'vuex';
import { FEATURE_FLAGS } from '../../../featureFlags';
export default {
components: {
@ -63,9 +61,6 @@ export default {
},
},
computed: {
...mapGetters({
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
}),
hasSecondaryMenu() {
return this.menuConfig.menuItems && this.menuConfig.menuItems.length;
},
@ -94,7 +89,7 @@ export default {
icon: 'folder',
label: 'INBOXES',
hasSubMenu: true,
newLink: this.showNewLink(FEATURE_FLAGS.INBOX_MANAGEMENT),
newLink: true,
newLinkTag: 'NEW_INBOX',
key: 'inbox',
toState: frontendURL(`accounts/${this.accountId}/settings/inboxes/new`),
@ -122,7 +117,7 @@ export default {
icon: 'number-symbol',
label: 'LABELS',
hasSubMenu: true,
newLink: this.showNewLink(FEATURE_FLAGS.TEAM_MANAGEMENT),
newLink: true,
newLinkTag: 'NEW_LABEL',
key: 'label',
toState: frontendURL(`accounts/${this.accountId}/settings/labels`),
@ -146,7 +141,7 @@ export default {
label: 'TAGGED_WITH',
hasSubMenu: true,
key: 'label',
newLink: this.showNewLink(FEATURE_FLAGS.TEAM_MANAGEMENT),
newLink: true,
newLinkTag: 'NEW_LABEL',
toState: frontendURL(`accounts/${this.accountId}/settings/labels`),
toStateName: 'labels_list',
@ -168,7 +163,7 @@ export default {
icon: 'people-team',
label: 'TEAMS',
hasSubMenu: true,
newLink: this.showNewLink(FEATURE_FLAGS.TEAM_MANAGEMENT),
newLink: true,
newLinkTag: 'NEW_TEAM',
key: 'team',
toState: frontendURL(`accounts/${this.accountId}/settings/teams/new`),
@ -243,9 +238,6 @@ export default {
toggleAccountModal() {
this.$emit('toggle-accounts');
},
showNewLink(featureFlag) {
return this.isFeatureEnabledonAccount(this.accountId, featureFlag);
},
},
};
</script>
@ -253,15 +245,20 @@ export default {
@import '~dashboard/assets/scss/woot';
.secondary-menu {
display: flex;
flex-direction: column;
background: var(--white);
border-right: 1px solid var(--s-50);
height: 100%;
width: 20rem;
flex-shrink: 0;
overflow-y: hidden;
@include breakpoint(xlarge down) {
position: absolute;
}
@include breakpoint(xlarge up) {
position: unset;
}
&:hover {
overflow-y: hidden;
@ -270,6 +267,7 @@ export default {
.menu {
padding: var(--space-small);
overflow-y: auto;
height: 94%;
}
}
</style>

View file

@ -26,7 +26,7 @@
:class="{ 'text-truncate': shouldTruncate }"
>
{{ label }}
<span v-if="showChildCount" class="count-view">
<span v-if="isHelpCenterSidebar && childItemCount" class="count-view">
{{ childItemCount }}
</span>
</span>
@ -76,7 +76,7 @@ export default {
type: String,
default: '',
},
showChildCount: {
isHelpCenterSidebar: {
type: Boolean,
default: false,
},
@ -112,7 +112,6 @@ $label-badge-size: var(--space-slab);
padding: var(--space-smaller) var(--space-smaller);
margin: var(--space-smaller) 0;
text-align: left;
line-height: 1.2;
&:hover {
background: var(--s-25);
@ -128,14 +127,11 @@ $label-badge-size: var(--space-slab);
color: var(--w-500);
border-color: var(--w-25);
}
&.is-active .count-view {
background: var(--w-75);
color: var(--w-500);
}
}
.menu-label {
flex-grow: 1;
line-height: var(--space-two);
}
.inbox-icon {
@ -179,6 +175,10 @@ $label-badge-size: var(--space-slab);
font-weight: var(--font-weight-bold);
margin-left: var(--space-smaller);
padding: var(--space-zero) var(--space-smaller);
line-height: var(--font-size-small);
&.is-active {
background: var(--w-50);
color: var(--w-500);
}
}
</style>

View file

@ -1,18 +1,19 @@
<template>
<li v-show="isMenuItemVisible" class="sidebar-item">
<li class="sidebar-item">
<div v-if="hasSubMenu" class="secondary-menu--wrap">
<span class="secondary-menu--header fs-small">
{{ $t(`SIDEBAR.${menuItem.label}`) }}
</span>
<div v-if="menuItem.showNewButton" class="submenu-icons">
<div v-if="isHelpCenterSidebar" class="submenu-icons">
<woot-button
size="tiny"
variant="clear"
color-scheme="secondary"
icon="add"
class="submenu-icon"
@click="onClickOpen"
/>
>
<fluent-icon icon="add" size="16" />
</woot-button>
</div>
</div>
<router-link
@ -27,11 +28,15 @@
size="14"
/>
{{ $t(`SIDEBAR.${menuItem.label}`) }}
<span v-if="showChildCount(menuItem.count)" class="count-view">
<span
v-if="isHelpCenterSidebar"
class="count-view"
:class="computedClass"
>
{{ `${menuItem.count}` }}
</span>
<span
v-if="menuItem.beta"
v-if="menuItem.label === 'AUTOMATION'"
data-view-component="true"
label="Beta"
class="beta"
@ -50,7 +55,7 @@
:should-truncate="child.truncateLabel"
:icon="computedInboxClass(child)"
:warning-icon="computedInboxErrorClass(child)"
:show-child-count="showChildCount(child.count)"
:is-help-center-sidebar="isHelpCenterSidebar"
:child-item-count="child.count"
/>
<router-link
@ -59,10 +64,10 @@
:to="menuItem.toState"
custom
>
<li class="menu-item--new">
<li>
<a
:href="href"
class="button small link clear secondary"
class="button small clear menu-item--new secondary"
:class="{ 'is-active': isActive }"
@click="e => newLinkClick(e, navigate)"
>
@ -73,6 +78,9 @@
</a>
</li>
</router-link>
<p v-if="isHelpCenterSidebar && isCategoryEmpty" class="empty-text">
{{ $t('SIDEBAR.HELP_CENTER.CATEGORY_EMPTY_MESSAGE') }}
</p>
</ul>
</li>
</template>
@ -87,10 +95,6 @@ import {
} from 'dashboard/helper/inbox';
import SecondaryChildNavItem from './SecondaryChildNavItem';
import {
isOnMentionsView,
isOnUnattendedView,
} from '../../../store/modules/conversations/helpers/actionHelpers';
export default {
components: { SecondaryChildNavItem },
@ -100,54 +104,33 @@ export default {
type: Object,
default: () => ({}),
},
isHelpCenterSidebar: {
type: Boolean,
default: false,
},
isCategoryEmpty: {
type: Boolean,
default: false,
},
},
computed: {
...mapGetters({
activeInbox: 'getSelectedInbox',
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
globalConfig: 'globalConfig/get',
}),
...mapGetters({ activeInbox: 'getSelectedInbox' }),
hasSubMenu() {
return !!this.menuItem.children;
},
isMenuItemVisible() {
if (this.menuItem.globalConfigFlag) {
return !!this.globalConfig[this.menuItem.globalConfigFlag];
}
if (this.menuItem.featureFlag) {
return this.isFeatureEnabledonAccount(
this.accountId,
this.menuItem.featureFlag
);
}
return true;
},
isAllConversations() {
isInboxConversation() {
return (
this.$store.state.route.name === 'inbox_conversation' &&
this.menuItem.toStateName === 'home'
);
},
isMentions() {
return (
isOnMentionsView({ route: this.$route }) &&
this.menuItem.toStateName === 'conversation_mentions'
);
},
isUnattended() {
return (
isOnUnattendedView({ route: this.$route }) &&
this.menuItem.toStateName === 'conversation_unattended'
);
},
isTeamsSettings() {
return (
this.$store.state.route.name === 'settings_teams_edit' &&
this.menuItem.toStateName === 'settings_teams_list'
);
},
isInboxSettings() {
isInboxsSettings() {
return (
this.$store.state.route.name === 'settings_inbox_show' &&
this.menuItem.toStateName === 'settings_inbox_list'
@ -165,25 +148,19 @@ export default {
this.menuItem.toStateName === 'settings_applications'
);
},
isCurrentRoute() {
return this.$store.state.route.name.includes(this.menuItem.toStateName);
isArticlesView() {
return this.$store.state.route.name === this.menuItem.toStateName;
},
computedClass() {
// If active inbox is present, do not highlight conversations
// If active Inbox is present
// donot highlight conversations
if (this.activeInbox) return ' ';
if (
this.isAllConversations ||
this.isMentions ||
this.isUnattended ||
this.isCurrentRoute
) {
return 'is-active';
}
if (this.hasSubMenu) {
if (
this.isInboxConversation ||
this.isTeamsSettings ||
this.isInboxSettings ||
this.isInboxsSettings ||
this.isIntegrationsSettings ||
this.isApplicationsSettings
) {
@ -191,7 +168,12 @@ export default {
}
return ' ';
}
if (this.isHelpCenterSidebar) {
if (this.isArticlesView) {
return 'is-active';
}
return ' ';
}
return '';
},
},
@ -222,14 +204,11 @@ export default {
}
},
showItem(item) {
return this.isAdmin && !!item.newLink;
return this.isAdmin && item.newLink !== undefined;
},
onClickOpen() {
this.$emit('open');
},
showChildCount(count) {
return Number.isInteger(count);
},
},
};
</script>
@ -285,11 +264,6 @@ export default {
color: var(--w-500);
border-color: var(--w-25);
}
&.is-active .count-view {
background: var(--w-75);
color: var(--w-600);
}
}
.secondary-menu--icon {
@ -319,19 +293,22 @@ export default {
top: -1px;
}
.sidebar-item .menu-item--new {
padding: var(--space-small) 0;
.button {
.sidebar-item .button.menu-item--new {
display: inline-flex;
height: var(--space-medium);
margin: var(--space-smaller) 0;
padding: var(--space-smaller);
color: var(--s-500);
&:hover {
color: var(--w-500);
}
}
.beta {
padding-right: var(--space-smaller) !important;
padding-left: var(--space-smaller) !important;
margin-left: var(--space-smaller) !important;
margin-left: var(--space-half) !important;
display: inline-block;
font-size: var(--font-size-micro);
font-weight: var(--font-weight-medium);
@ -350,6 +327,11 @@ export default {
font-weight: var(--font-weight-bold);
margin-left: var(--space-smaller);
padding: var(--space-zero) var(--space-smaller);
&.is-active {
background: var(--w-50);
color: var(--w-500);
}
}
.submenu-icons {
@ -361,4 +343,10 @@ export default {
margin-left: var(--space-small);
}
}
.empty-text {
color: var(--s-500);
font-size: var(--font-size-small);
margin: var(--space-smaller);
}
</style>

View file

@ -1,11 +1,10 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SidemenuIcon matches snapshot 1`] = `
<woot-button
class="toggle-sidebar"
color-scheme="secondary"
<button>
<fluent-icon
class="hamburger--menu"
icon="list"
size="small"
variant="clear"
/>
/>
</button>
`;

View file

@ -4,7 +4,7 @@
<fluent-icon :icon="icon" size="12" class="label--icon" />
</span>
<span
v-if="variant === 'smooth' && title && !icon"
v-if="variant === 'smooth'"
:style="{ background: color }"
class="label-color-dot"
/>
@ -117,16 +117,14 @@ export default {
height: var(--space-medium);
&.small {
font-size: var(--font-size-mini);
font-size: var(--font-size-micro);
padding: var(--space-micro) var(--space-smaller);
line-height: 1.2;
height: var(--space-two);
letter-spacing: 0.15px;
}
.label--icon {
cursor: pointer;
}
.label-color-dot {
margin-right: var(--space-smaller);
}
@ -201,8 +199,8 @@ export default {
&.smooth {
background: transparent;
border: 1px solid var(--s-100);
color: var(--s-700);
border: 1px solid var(--s-75);
color: var(--s-800);
}
}
@ -223,22 +221,14 @@ export default {
}
.label-action--button {
display: flex;
margin-right: var(--space-smaller);
margin-bottom: var(--space-minus-micro);
}
.label-color-dot {
display: inline-block;
width: var(--space-slab);
height: var(--space-slab);
width: var(--space-one);
height: var(--space-one);
border-radius: var(--border-radius-small);
margin-right: var(--space-smaller);
box-shadow: var(--shadow-small);
}
.label.small .label-color-dot {
width: var(--space-small);
height: var(--space-small);
border-radius: var(--border-radius-small);
box-shadow: var(--shadow-small);
}
</style>

View file

@ -1,113 +0,0 @@
<template>
<div class="preview-card--wrap" :class="{ activecard: active }">
<div class="header--wrap" :class="{ active: active }">
<div class="heading-wrap text-block-title">{{ heading }}</div>
<fluent-icon
v-if="active"
icon="checkmark-circle"
type="solid"
size="24"
class="checkmark"
/>
</div>
<div class="content-wrap">
{{ content }}
</div>
<div class="image-wrap">
<img :src="src" class="image" :class="{ activeimage: active }" />
</div>
</div>
</template>
<script>
export default {
props: {
heading: {
type: String,
default: '',
},
content: {
type: String,
default: '',
},
active: {
type: Boolean,
default: false,
},
buttonText: {
type: String,
default: 'Active',
},
src: {
type: String,
default: '',
},
},
};
</script>
<style lang="scss" scoped>
.preview-card--wrap {
border-radius: var(--border-radius-normal);
border: 1px solid var(--color-border);
display: flex;
flex-direction: column;
max-height: 34rem;
max-width: 38rem;
min-width: 24rem;
.header--wrap {
background: var(--s-50);
border-bottom: 1px solid var(--color-border);
border-top-left-radius: var(--border-radius-normal);
border-top-right-radius: var(--border-radius-normal);
display: flex;
height: 4rem;
justify-content: space-between;
padding: var(--space-small);
width: 100%;
}
.active {
background: var(--w-50);
border-bottom: 1px solid var(--w-75);
}
.heading-wrap {
align-items: center;
display: flex;
font-weight: var(--font-weight-medium);
padding: var(--space-smaller);
}
.checkmark {
color: var(--w-500);
}
.content-wrap {
color: var(--s-700);
font-size: var(--font-size-mini);
line-height: 1.4;
padding: var(--space-slab) var(--space-slab) 0 var(--space-slab);
text-align: start;
}
.image-wrap {
padding: var(--space-slab);
}
.image {
border: 1px solid var(--color-border);
border-radius: var(--border-radius-normal);
}
.activeimage {
border: 1px solid var(--w-75);
}
}
.activecard {
background: var(--w-25);
border: 1px solid var(--w-300);
}
</style>

View file

@ -2,7 +2,7 @@
<button
type="button"
class="toggle-button"
:class="{ active: value, small: size === 'small' }"
:class="{ active: value }"
role="switch"
:aria-checked="value.toString()"
@click="onClick"
@ -15,7 +15,6 @@
export default {
props: {
value: { type: Boolean, default: false },
size: { type: String, default: '' },
},
methods: {
onClick() {
@ -46,20 +45,6 @@ export default {
background-color: var(--w-500);
}
&.small {
width: 22px;
height: 14px;
span {
height: var(--space-one);
width: var(--space-one);
&.active {
transform: translate(var(--space-small), var(--space-zero));
}
}
}
span {
--space-one-point-five: 1.5rem;
background-color: var(--white);

View file

@ -1,15 +1,17 @@
<template>
<span class="time-ago">
<span>{{ timeAgo }}</span>
<span> {{ timeAgo }}</span>
</span>
</template>
<script>
const ZERO = 0;
const MINUTE_IN_MILLI_SECONDS = 60000;
const HOUR_IN_MILLI_SECONDS = MINUTE_IN_MILLI_SECONDS * 60;
const DAY_IN_MILLI_SECONDS = HOUR_IN_MILLI_SECONDS * 24;
import timeMixin from 'dashboard/mixins/time';
import { differenceInMilliseconds } from 'date-fns';
export default {
name: 'TimeAgo',
@ -26,40 +28,51 @@ export default {
},
data() {
return {
timeAgo: this.dynamicTime(this.timestamp),
timeAgo: '',
timer: null,
};
},
watch: {
timestamp() {
this.timeAgo = this.dynamicTime(this.timestamp);
},
},
mounted() {
if (this.isAutoRefreshEnabled) {
this.createTimer();
}
},
beforeDestroy() {
clearTimeout(this.timer);
},
methods: {
createTimer() {
this.timer = setTimeout(() => {
this.timeAgo = this.dynamicTime(this.timestamp);
this.createTimer();
}, this.refreshTime());
},
computed: {
refreshTime() {
const timeDiff = Date.now() - this.timestamp * 1000;
const timeDiff = differenceInMilliseconds(
new Date(),
new Date(this.timestamp * 1000)
);
if (timeDiff > DAY_IN_MILLI_SECONDS) {
return DAY_IN_MILLI_SECONDS;
}
if (timeDiff > HOUR_IN_MILLI_SECONDS) {
return HOUR_IN_MILLI_SECONDS;
}
if (timeDiff > MINUTE_IN_MILLI_SECONDS) {
return MINUTE_IN_MILLI_SECONDS;
}
return ZERO;
},
},
mounted() {
this.timeAgo = this.dynamicTime(this.timestamp);
if (this.isAutoRefreshEnabled) {
this.createTimer();
}
},
beforeDestroy() {
this.clearTimer();
},
methods: {
createTimer() {
const refreshTime = this.refreshTime;
if (refreshTime > ZERO) {
this.timer = setTimeout(() => {
this.timeAgo = this.dynamicTime(this.timestamp);
this.createTimer();
}, refreshTime);
}
},
clearTimer() {
if (this.timer) {
clearTimeout(this.timer);
}
},
},
};

View file

@ -1,5 +1,8 @@
<template>
<div class="filter" :class="actionInputStyles">
<div
class="filter"
:class="{ error: v.action_params.$dirty && v.action_params.$error }"
>
<div class="filter-inputs">
<select
v-model="action_name"
@ -18,32 +21,14 @@
<div v-if="showActionInput" class="filter__answer--wrap">
<div v-if="inputType">
<div
v-if="inputType === 'search_select'"
v-if="inputType === 'multi_select'"
class="multiselect-wrap--small"
>
<multiselect
v-model="action_params"
track-by="id"
label="name"
:placeholder="$t('FORMS.MULTISELECT.SELECT')"
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
deselect-label=""
:max-height="160"
:options="dropdownValues"
:allow-empty="false"
:option-height="104"
/>
</div>
<div
v-else-if="inputType === 'multi_select'"
class="multiselect-wrap--small"
>
<multiselect
v-model="action_params"
track-by="id"
label="name"
:placeholder="$t('FORMS.MULTISELECT.SELECT')"
:placeholder="'Select'"
:multiple="true"
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
@ -51,7 +36,6 @@
:max-height="160"
:options="dropdownValues"
:allow-empty="false"
:option-height="104"
/>
</div>
<input
@ -76,7 +60,6 @@
</div>
</div>
<woot-button
v-if="!isMacro"
icon="dismiss"
variant="clear"
color-scheme="secondary"
@ -137,10 +120,6 @@ export default {
type: String,
default: '',
},
isMacro: {
type: Boolean,
default: false,
},
},
computed: {
action_name: {
@ -167,12 +146,6 @@ export default {
return this.actionTypes.find(action => action.key === this.action_name)
.inputType;
},
actionInputStyles() {
return {
'has-error': this.v.action_params.$dirty && this.v.action_params.$error,
'is-a-macro': this.isMacro,
};
},
},
methods: {
removeAction() {
@ -192,21 +165,9 @@ export default {
border: 1px solid var(--color-border);
border-radius: var(--border-radius-medium);
margin-bottom: var(--space-small);
&.is-a-macro {
margin-bottom: 0;
background: var(--white);
padding: var(--space-zero);
border: unset;
border-radius: unset;
}
}
.no-margin-bottom {
margin-bottom: 0;
}
.filter.has-error {
.filter.error {
background: var(--r-50);
}
@ -279,6 +240,6 @@ export default {
margin-bottom: var(--space-zero);
}
.action-message {
margin: var(--space-small) var(--space-zero) var(--space-zero);
margin: var(--space-small) 0 0;
}
</style>

Some files were not shown because too many files have changed in this diff Show more