Compare commits

...
Author SHA1 Message Date
Shivam Mishra 0a27c3fcbf refactor: simplify error handling 2025-06-02 20:33:50 +05:30
Shivam Mishra 3197fafad1 chore: remove unused errors 2025-06-02 20:30:06 +05:30
Shivam Mishra c5cc972a74 feat: add specs 2025-06-02 20:29:57 +05:30
Shivam Mishra d0a7de0b00 feat: handle oauth scope verification and errors 2025-06-02 20:17:54 +05:30
Shivam Mishra a7aae69229 feat: separate scope methods 2025-06-02 19:51:32 +05:30
Shivam Mishra 12957b7438 refactor: common oauth_authorization_controller 2025-06-02 18:35:49 +05:30
Shivam MishraandGitHub 363169667d Merge branch 'develop' into fix/auth-methods 2025-06-02 18:25:01 +05:30
Sivin VargheseandGitHub a5fda8e118 fix: Style issue with CSAT reports table (#11653) 2025-06-02 14:08:01 +05:30
Shivam MishraandGitHub 8fa039e1c5 fix: route params not reacting to changes (#11651) 2025-06-02 13:57:42 +05:30
PranavandGitHub 4061f99114 fix: Force re-render the CSAT component when data changes (#11643) 2025-06-02 11:10:47 +05:30
f064b09776 feat: move embedding config to a yaml file (#11611)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2025-05-30 16:26:40 +05:30
Shivam MishraandGitHub 07a39f4b42 feat: Enforce role permissions on filtered page (#11638)
Fixes: https://github.com/chatwoot/chatwoot/issues/11610

Demo:
https://www.loom.com/share/c9181b42619044379ba01e0ac913801d?sid=e306fe30-ce80-47ac-83e5-92132a99f464
2025-05-30 15:19:42 +05:30
Sojan JoseandGitHub 8bbf6c75e3 chore: Improvements to codespaces (#11635)
- Remove the deprecated extensions
- Install claude code in base image 
- Fix rails deprecation warning
2025-05-29 21:18:02 -06:00
PranavandGitHub aad6d655d5 fix: Update design to fix the crowded header (#11633)
**Before:**

<img width="907" alt="Screenshot 2025-05-29 at 3 21 00 PM"
src="https://github.com/user-attachments/assets/7738f684-7e9f-40ff-ac49-d9b389eca99b"
/>

**After:**
<img width="903" alt="Screenshot 2025-05-29 at 3 20 33 PM"
src="https://github.com/user-attachments/assets/1213d832-59d8-4d04-be96-f711297a887d"
/>
2025-05-29 18:45:28 -06:00
PranavandGitHub 70c29f699c fix(revert): "fix: Send CSAT survey only when agent can reply in conversati… (#11634)
…on (#11584)"

This reverts commit b5ebc47637.

# Pull Request Template

## Description

Please include a summary of the change and issue(s) fixed. Also, mention
relevant motivation, context, and any dependencies that this change
requires.
Fixes # (issue)

## Type of change

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)
- [ ] 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.


## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2025-05-29 16:52:33 -06:00
Sojan JoseandGitHub 4ab0b17e6e feat: add custom roles seeding to account seeder (#11623)
Introduce custom roles in account seed data to support testing of
realistic role-based access scenarios. Includes six distinct roles with
varied permission sets and assigns them to multiple users for
comprehensive test coverage.
2025-05-29 15:17:20 -06:00
Sojan JoseandGitHub 3548948c92 fix: Allow users with report_manage permission to access CSAT reports (#11625)
- Extended CsatSurveyResponsePolicy to support report_manage permission
- Added enterprise extension module following existing pattern
- Users with report_manage custom role can now access CSAT index,
metrics, and download
- Added comprehensive tests for both base and enterprise policy behavior
2025-05-29 12:09:03 -06:00
Sivin VargheseandGitHub b1898e019b fix: Design issues with profile settings style (#11630)
# Pull Request Template

## Description

This PR fixes styles issues with the select in profile settings and
toggles the Hotkeys button.

## Type of change

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

## How Has This Been Tested?

### Screenshots

**Select**
| Before  | After (Dark Mode) | After (Light Mode) |
| ------------- | ------------- | ------------- |
| <img width="162" alt="image"
src="https://github.com/user-attachments/assets/6d4ebd45-d233-413e-aca8-7f4db5d6c8a2"
/> <img width="699" alt="image"
src="https://github.com/user-attachments/assets/62c2ca85-479f-4252-a4fc-ffd5a570cff0"
/> | <img width="162" alt="image"
src="https://github.com/user-attachments/assets/7c9780ea-8ab6-429e-82e3-6fb554141bc0"
/> <img width="699" alt="image"
src="https://github.com/user-attachments/assets/726d7a0a-bfbc-4bdc-aedc-a874146230d8"
/> | <img width="162" alt="image"
src="https://github.com/user-attachments/assets/f3022b5f-6ae2-48d6-a2c9-9961f4295b81"
/> <img width="699" alt="image"
src="https://github.com/user-attachments/assets/fa6515c1-35df-4f6f-9096-e7893bffe683"
/> |

**Toggle Hotkeys**
| Before  | After (Dark Mode) | After (Light Mode) |
| ------------- | ------------- | ------------- |
| <img width="783" alt="image"
src="https://github.com/user-attachments/assets/5628fa6a-9692-44f4-bc45-6e5de44588c7"
/> | <img width="783" alt="image"
src="https://github.com/user-attachments/assets/14529e80-fd9c-46cd-9ad8-aab041781238"
/> | <img width="783" alt="image"
src="https://github.com/user-attachments/assets/dfe2cbec-8d05-443d-89a2-0e674fffa518"
/> |

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2025-05-29 12:07:05 -06:00
Shivam MishraandGitHub b3a76289cc feat: use tracking pixel for article view count (#11559) 2025-05-29 17:01:38 +05:30
Shivam MishraandGitHub 236b7d4541 Merge branch 'develop' into fix/auth-methods 2025-05-29 16:56:00 +05:30
873cfa08d8 feat: Ability to reset api_access_token (#11565)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2025-05-29 14:42:13 +05:30
a0cc27faaf feat: show articles in the list only if feature is enabled (#11607)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2025-05-29 14:19:35 +05:30
Shivam MishraandGitHub 6d101f5ba3 Merge branch 'develop' into fix/auth-methods 2025-05-28 17:18:40 +05:30
Shivam MishraandGitHub c88fb147f8 Merge branch 'develop' into fix/auth-methods 2025-05-26 16:58:19 +05:30
Shivam Mishra ebdca08da7 feat: update re-auth ui 2025-04-24 15:16:13 +05:30
Shivam Mishra 8150604a54 feat: use state for authorization instead of caching email 2025-04-24 15:08:52 +05:30
83 changed files with 1813 additions and 907 deletions
+2 -2
View File
@@ -77,8 +77,8 @@ RUN curl -L https://github.com/DarthSim/overmind/releases/download/v2.1.0/overmi
WORKDIR /workspace WORKDIR /workspace
RUN chown vscode:vscode /workspace RUN chown vscode:vscode /workspace
# set up node js and pnpm in single layer # set up node js, pnpm and claude code in single layer
RUN npm install -g pnpm@${PNPM_VERSION} \ RUN npm install -g pnpm@${PNPM_VERSION} @anthropic-ai/claude-code \
&& npm cache clean --force && npm cache clean --force
# Switch to vscode user # Switch to vscode user
+13 -4
View File
@@ -4,17 +4,26 @@
"dockerComposeFile": "docker-compose.yml", "dockerComposeFile": "docker-compose.yml",
"settings": { "settings": {
"terminal.integrated.shell.linux": "/bin/zsh" "terminal.integrated.shell.linux": "/bin/zsh",
"extensions.showRecommendationsOnlyOnDemand": true,
"editor.formatOnSave": true,
"files.trimTrailingWhitespace": true,
"files.insertFinalNewline": true,
"search.exclude": {
"**/node_modules": true,
"**/tmp": true,
"**/log": true,
"**/coverage": true,
"**/public/packs": true
}
}, },
// Add the IDs of extensions you want installed when the container is created. // Add the IDs of extensions you want installed when the container is created.
"extensions": [ "extensions": [
"rebornix.Ruby", "Shopify.ruby-lsp",
"misogi.ruby-rubocop", "misogi.ruby-rubocop",
"wingrunr21.vscode-ruby",
"davidpallinder.rails-test-runner", "davidpallinder.rails-test-runner",
"eamodio.gitlens",
"github.copilot", "github.copilot",
"mrmlnc.vscode-duplicate" "mrmlnc.vscode-duplicate"
], ],
+8
View File
@@ -4,5 +4,13 @@ sed -i -e '/POSTGRES_HOST/ s/=.*/=localhost/' .env
sed -i -e '/SMTP_ADDRESS/ s/=.*/=localhost/' .env sed -i -e '/SMTP_ADDRESS/ s/=.*/=localhost/' .env
sed -i -e "/FRONTEND_URL/ s/=.*/=https:\/\/$CODESPACE_NAME-3000.app.github.dev/" .env sed -i -e "/FRONTEND_URL/ s/=.*/=https:\/\/$CODESPACE_NAME-3000.app.github.dev/" .env
# Setup Claude Code API key if available
if [ -n "$CLAUDE_CODE_API_KEY" ]; then
mkdir -p ~/.claude
echo '{"apiKeyHelper": "~/.claude/anthropic_key.sh"}' > ~/.claude/settings.json
echo "echo \"$CLAUDE_CODE_API_KEY\"" > ~/.claude/anthropic_key.sh
chmod +x ~/.claude/anthropic_key.sh
fi
# codespaces make the ports public # codespaces make the ports public
gh codespace ports visibility 3000:public 3036:public 8025:public -c $CODESPACE_NAME gh codespace ports visibility 3000:public 3036:public 8025:public -c $CODESPACE_NAME
+1
View File
@@ -41,6 +41,7 @@ run:
force_run: force_run:
rm -f ./.overmind.sock rm -f ./.overmind.sock
rm -f tmp/pids/*.pid
overmind start -f Procfile.dev overmind start -f Procfile.dev
debug: debug:
@@ -29,6 +29,11 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
head :ok head :ok
end end
def reset_access_token
@agent_bot.access_token.regenerate_token
@agent_bot.reload
end
private private
def agent_bot def agent_bot
@@ -1,32 +1,23 @@
class Api::V1::Accounts::Google::AuthorizationsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::Google::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController
include GoogleConcern include GoogleConcern
before_action :check_authorization
def create def create
email = params[:authorization][:email]
redirect_url = google_client.auth_code.authorize_url( redirect_url = google_client.auth_code.authorize_url(
{ {
redirect_uri: "#{base_url}/google/callback", redirect_uri: "#{base_url}/google/callback",
scope: 'email profile https://mail.google.com/', scope: scope,
response_type: 'code', response_type: 'code',
prompt: 'consent', # the oauth flow does not return a refresh token, this is supposed to fix it prompt: 'consent', # the oauth flow does not return a refresh token, this is supposed to fix it
access_type: 'offline', # the default is 'online' access_type: 'offline', # the default is 'online'
state: state,
client_id: GlobalConfigService.load('GOOGLE_OAUTH_CLIENT_ID', nil) client_id: GlobalConfigService.load('GOOGLE_OAUTH_CLIENT_ID', nil)
} }
) )
if redirect_url if redirect_url
cache_key = "google::#{email.downcase}"
::Redis::Alfred.setex(cache_key, Current.account.id, 5.minutes)
render json: { success: true, url: redirect_url } render json: { success: true, url: redirect_url }
else else
render json: { success: false }, status: :unprocessable_entity render json: { success: false }, status: :unprocessable_entity
end end
end end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end end
@@ -1,7 +1,6 @@
class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController
include InstagramConcern include InstagramConcern
include Instagram::IntegrationHelper include Instagram::IntegrationHelper
before_action :check_authorization
def create def create
# https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#step-1--get-authorization # https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#step-1--get-authorization
@@ -21,10 +20,4 @@ class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts
render json: { success: false }, status: :unprocessable_entity render json: { success: false }, status: :unprocessable_entity
end end
end end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end end
@@ -1,28 +1,19 @@
class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController
include MicrosoftConcern include MicrosoftConcern
before_action :check_authorization
def create def create
email = params[:authorization][:email]
redirect_url = microsoft_client.auth_code.authorize_url( redirect_url = microsoft_client.auth_code.authorize_url(
{ {
redirect_uri: "#{base_url}/microsoft/callback", redirect_uri: "#{base_url}/microsoft/callback",
scope: 'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send openid profile', scope: scope,
state: state,
prompt: 'consent' prompt: 'consent'
} }
) )
if redirect_url if redirect_url
cache_key = "microsoft::#{email.downcase}"
::Redis::Alfred.setex(cache_key, Current.account.id, 5.minutes)
render json: { success: true, url: redirect_url } render json: { success: true, url: redirect_url }
else else
render json: { success: false }, status: :unprocessable_entity render json: { success: false }, status: :unprocessable_entity
end end
end end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end end
@@ -0,0 +1,23 @@
class Api::V1::Accounts::OauthAuthorizationController < Api::V1::Accounts::BaseController
before_action :check_authorization
protected
def scope
''
end
def state
Current.account.to_sgid(expires_in: 15.minutes).to_s
end
def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end
@@ -38,6 +38,11 @@ class Api::V1::ProfilesController < Api::BaseController
head :ok head :ok
end end
def reset_access_token
@user.access_token.regenerate_token
@user.reload
end
private private
def set_user def set_user
+2 -2
View File
@@ -14,7 +14,7 @@ module GoogleConcern
private private
def base_url def scope
ENV.fetch('FRONTEND_URL', 'http://localhost:3000') 'https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email https://mail.google.com/'
end end
end end
@@ -15,7 +15,7 @@ module MicrosoftConcern
private private
def base_url def scope
ENV.fetch('FRONTEND_URL', 'http://localhost:3000') 'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send openid profile'
end end
end end
@@ -12,6 +12,16 @@ class Google::CallbacksController < OauthCallbackController
private private
def verify_scopes
granted_scopes = parsed_body['scope']&.split || []
required_scopes = scope.split
missing_scopes = required_scopes - granted_scopes
return if missing_scopes.empty?
raise StandardError, I18n.t('errors.oauth.insufficient_scopes', scopes: missing_scopes.join(', '))
end
def provider_name def provider_name
'google' 'google'
end end
@@ -24,4 +34,13 @@ class Google::CallbacksController < OauthCallbackController
# from GoogleConcern # from GoogleConcern
google_client google_client
end end
def handle_error(exception)
ChatwootExceptionTracker.new(exception).capture_exception
error_message = exception.message || I18n.t('errors.oauth.authorization_failed')
redirect_url = "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/app/accounts/#{account.id}/settings/inboxes/new/email"
redirect_to "#{redirect_url}?error=#{CGI.escape(error_message)}"
end
end end
+18 -8
View File
@@ -5,11 +5,10 @@ class OauthCallbackController < ApplicationController
redirect_uri: "#{base_url}/#{provider_name}/callback" redirect_uri: "#{base_url}/#{provider_name}/callback"
) )
verify_scopes
handle_response handle_response
::Redis::Alfred.delete(cache_key)
rescue StandardError => e rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception handle_error(e)
redirect_to '/'
end end
private private
@@ -64,8 +63,17 @@ class OauthCallbackController < ApplicationController
raise NotImplementedError raise NotImplementedError
end end
def cache_key def verify_scopes
"#{provider_name}::#{users_data['email'].downcase}" true
end
def failure_redirect_url
'/'
end
def handle_error(exception)
ChatwootExceptionTracker.new(exception).capture_exception
redirect_to failure_redirect_url
end end
def create_channel_with_inbox def create_channel_with_inbox
@@ -85,12 +93,14 @@ class OauthCallbackController < ApplicationController
decoded_token[0] decoded_token[0]
end end
def account_id def account_from_signed_id
::Redis::Alfred.get(cache_key) raise 'Missing state variable' if params[:state].blank?
GlobalID::Locator.locate_signed(params[:state])
end end
def account def account
@account ||= Account.find(account_id) @account ||= account_from_signed_id
end end
# Fallback name, for when name field is missing from users_data # Fallback name, for when name field is missing from users_data
@@ -1,7 +1,7 @@
class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController
before_action :ensure_custom_domain_request, only: [:show, :index] before_action :ensure_custom_domain_request, only: [:show, :index]
before_action :portal before_action :portal
before_action :set_category, except: [:index, :show] before_action :set_category, except: [:index, :show, :tracking_pixel]
before_action :set_article, only: [:show] before_action :set_article, only: [:show]
layout 'portal' layout 'portal'
@@ -15,6 +15,21 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
def show; end def show; end
def tracking_pixel
@article = @portal.articles.find_by(slug: permitted_params[:article_slug])
return head :not_found unless @article
@article.increment_view_count if @article.published?
# Serve the 1x1 tracking pixel with 24-hour private cache
# Private cache bypasses CDN but allows browser caching to prevent duplicate views from same user
expires_in 24.hours, public: false
response.headers['Content-Type'] = 'image/png'
pixel_path = Rails.public_path.join('assets/images/tracking-pixel.png')
send_file pixel_path, type: 'image/png', disposition: 'inline'
end
private private
def limit_results def limit_results
@@ -39,7 +54,6 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
def set_article def set_article
@article = @portal.articles.find_by(slug: permitted_params[:article_slug]) @article = @portal.articles.find_by(slug: permitted_params[:article_slug])
@article.increment_view_count if @article.published?
@parsed_content = render_article_content(@article.content) @parsed_content = render_article_content(@article.content)
end end
@@ -21,6 +21,10 @@ class AgentBotsAPI extends ApiClient {
deleteAgentBotAvatar(botId) { deleteAgentBotAvatar(botId) {
return axios.delete(`${this.url}/${botId}/avatar`); return axios.delete(`${this.url}/${botId}/avatar`);
} }
resetAccessToken(botId) {
return axios.post(`${this.url}/${botId}/reset_access_token`);
}
} }
export default new AgentBotsAPI(); export default new AgentBotsAPI();
+4
View File
@@ -102,4 +102,8 @@ export default {
const urlData = endPoints('resendConfirmation'); const urlData = endPoints('resendConfirmation');
return axios.post(urlData.url); return axios.post(urlData.url);
}, },
resetAccessToken() {
const urlData = endPoints('resetAccessToken');
return axios.post(urlData.url);
},
}; };
@@ -51,6 +51,9 @@ const endPoints = {
resendConfirmation: { resendConfirmation: {
url: '/api/v1/profile/resend_confirmation', url: '/api/v1/profile/resend_confirmation',
}, },
resetAccessToken: {
url: '/api/v1/profile/reset_access_token',
},
}; };
export default page => { export default page => {
@@ -9,5 +9,6 @@ describe('#AgentBotsAPI', () => {
expect(AgentBotsAPI).toHaveProperty('create'); expect(AgentBotsAPI).toHaveProperty('create');
expect(AgentBotsAPI).toHaveProperty('update'); expect(AgentBotsAPI).toHaveProperty('update');
expect(AgentBotsAPI).toHaveProperty('delete'); expect(AgentBotsAPI).toHaveProperty('delete');
expect(AgentBotsAPI).toHaveProperty('resetAccessToken');
}); });
}); });
@@ -101,7 +101,7 @@ select {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' version='1.1' width='32' height='24' viewBox='0 0 32 24'><polygon points='0,0 32,0 16,24' style='fill: rgb%28110, 111, 115%29'></polygon></svg>"); background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' version='1.1' width='32' height='24' viewBox='0 0 32 24'><polygon points='0,0 32,0 16,24' style='fill: rgb%28110, 111, 115%29'></polygon></svg>");
background-size: 9px 6px; background-size: 9px 6px;
@apply field-base h-10 bg-origin-content focus-visible:outline-none bg-no-repeat py-2 ltr:bg-[right_-1rem_center] rtl:bg-[left_-1rem_center] ltr:pr-6 rtl:pl-6 rtl:pr-3 ltr:pl-3; @apply field-base h-10 bg-origin-content bg-no-repeat py-2 ltr:bg-[right_-1rem_center] rtl:bg-[left_-1rem_center] ltr:pr-6 rtl:pl-6 rtl:pr-3 ltr:pl-3;
&[disabled] { &[disabled] {
@apply field-disabled; @apply field-disabled;
@@ -0,0 +1,41 @@
<script setup>
import ConfirmButton from './ConfirmButton.vue';
import { ref } from 'vue';
const count = ref(0);
const incrementCount = () => {
count.value += 1;
};
</script>
<template>
<Story
title="Components/ConfirmButton"
:layout="{ type: 'grid', width: '400px' }"
>
<Variant title="Basic">
<div class="grid gap-2 p-4 bg-white dark:bg-slate-900">
<p>{{ count }}</p>
<ConfirmButton
label="Delete"
confirm-label="Confirm?"
@click="incrementCount"
/>
</div>
</Variant>
<Variant title="Color Change">
<div class="grid gap-2 p-4 bg-white dark:bg-slate-900">
<p>{{ count }}</p>
<ConfirmButton
label="Archive"
confirm-label="Confirm?"
color="slate"
confirm-color="amber"
@click="incrementCount"
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,99 @@
<script setup>
import { ref, computed } from 'vue';
import Button from './Button.vue';
const props = defineProps({
label: { type: [String, Number], default: '' },
confirmLabel: { type: [String, Number], default: '' },
color: { type: String, default: 'blue' },
confirmColor: { type: String, default: 'ruby' },
confirmHint: { type: String, default: '' },
variant: { type: String, default: null },
size: { type: String, default: null },
justify: { type: String, default: null },
icon: { type: [String, Object, Function], default: '' },
trailingIcon: { type: Boolean, default: false },
isLoading: { type: Boolean, default: false },
});
const emit = defineEmits(['click']);
const isConfirmMode = ref(false);
const isClicked = ref(false);
const currentLabel = computed(() => {
return isConfirmMode.value ? props.confirmLabel : props.label;
});
const currentColor = computed(() => {
return isConfirmMode.value ? props.confirmColor : props.color;
});
const resetConfirmMode = () => {
isConfirmMode.value = false;
isClicked.value = false;
};
const handleClick = () => {
if (!isConfirmMode.value) {
isConfirmMode.value = true;
} else {
isClicked.value = true;
emit('click');
setTimeout(resetConfirmMode, 400);
}
};
</script>
<template>
<div
class="relative"
:class="{
'animate-bounce-complete': isClicked,
}"
>
<Button
type="button"
:label="currentLabel"
:color="currentColor"
:variant="variant"
:size="size"
:justify="justify"
:icon="icon"
:trailing-icon="trailingIcon"
:is-loading="isLoading"
@click="handleClick"
@blur="resetConfirmMode"
>
<template v-if="$slots.default" #default>
<slot />
</template>
<template v-if="$slots.icon" #icon>
<slot name="icon" />
</template>
</Button>
<div
v-if="isConfirmMode && confirmHint"
class="absolute mt-1 w-full text-[10px] text-center text-n-slate-10"
>
{{ confirmHint }}
</div>
</div>
</template>
<style scoped>
@keyframes bounce-complete {
0% {
transform: scale(0.95);
}
50% {
transform: scale(1.02);
}
100% {
transform: scale(1);
}
}
.animate-bounce-complete {
animation: bounce-complete 0.2s cubic-bezier(0.68, -0.55, 0.265, 1.55);
}
</style>
@@ -477,7 +477,7 @@ const menuItems = computed(() => {
<template> <template>
<aside <aside
class="w-[12.5rem] bg-n-solid-2 rtl:border-l ltr:border-r border-n-weak h-screen flex flex-col text-sm pb-1" class="w-[200px] bg-n-solid-2 rtl:border-l ltr:border-r border-n-weak h-screen flex flex-col text-sm pb-1"
> >
<section class="grid gap-2 mt-2 mb-4"> <section class="grid gap-2 mt-2 mb-4">
<div class="flex items-center min-w-0 gap-2 px-2"> <div class="flex items-center min-w-0 gap-2 px-2">
@@ -25,10 +25,7 @@ defineProps({
:username="user.name" :username="user.name"
:status="user.availability_status" :status="user.availability_status"
/> />
<span <span class="my-0 truncate text-capitalize" :class="textClass">
class="my-0 overflow-hidden whitespace-nowrap text-ellipsis text-capitalize"
:class="textClass"
>
{{ user.name }} {{ user.name }}
</span> </span>
</div> </div>
@@ -99,7 +99,6 @@ export default {
<ConversationHeader <ConversationHeader
v-if="currentChat.id" v-if="currentChat.id"
:chat="currentChat" :chat="currentChat"
:is-inbox-view="isInboxView"
:show-back-button="isOnExpandedLayout && !isInboxView" :show-back-button="isOnExpandedLayout && !isInboxView"
/> />
<woot-tabs <woot-tabs
@@ -1,7 +1,9 @@
<script> <script setup>
import { mapGetters } from 'vuex'; import { computed, ref } from 'vue';
import { useRoute } from 'vue-router';
import { useStore } from 'vuex';
import { useElementSize } from '@vueuse/core';
import BackButton from '../BackButton.vue'; import BackButton from '../BackButton.vue';
import inboxMixin from 'shared/mixins/inboxMixin';
import InboxName from '../InboxName.vue'; import InboxName from '../InboxName.vue';
import MoreActions from './MoreActions.vue'; import MoreActions from './MoreActions.vue';
import Thumbnail from '../Thumbnail.vue'; import Thumbnail from '../Thumbnail.vue';
@@ -11,176 +13,161 @@ import { conversationListPageURL } from 'dashboard/helper/URLHelper';
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers'; import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
import { FEATURE_FLAGS } from 'dashboard/featureFlags'; import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import Linear from './linear/index.vue'; import Linear from './linear/index.vue';
import { useInbox } from 'dashboard/composables/useInbox';
import { useI18n } from 'vue-i18n';
import NextButton from 'dashboard/components-next/button/Button.vue'; const props = defineProps({
chat: {
type: Object,
default: () => ({}),
},
showBackButton: {
type: Boolean,
default: false,
},
});
export default { const { t } = useI18n();
components: { const store = useStore();
BackButton, const route = useRoute();
InboxName, const conversationHeader = ref(null);
MoreActions, const { width } = useElementSize(conversationHeader);
Thumbnail, const { isAWebWidgetInbox } = useInbox();
SLACardLabel,
Linear, const currentChat = computed(() => store.getters.getSelectedChat);
NextButton, const accountId = computed(() => store.getters.getCurrentAccountId);
}, const isFeatureEnabledonAccount = computed(
mixins: [inboxMixin], () => store.getters['accounts/isFeatureEnabledonAccount']
props: { );
chat: { const appIntegrations = computed(
type: Object, () => store.getters['integrations/getAppIntegrations']
default: () => ({}), );
},
showBackButton: { const chatMetadata = computed(() => props.chat.meta);
type: Boolean,
default: false, const backButtonUrl = computed(() => {
}, const {
isInboxView: { params: { inbox_id: inboxId, label, teamId },
type: Boolean, name,
default: false, } = route;
}, return conversationListPageURL({
}, accountId,
computed: { inboxId,
...mapGetters({ label,
currentChat: 'getSelectedChat', teamId,
accountId: 'getCurrentAccountId', conversationType: name === 'conversation_mentions' ? 'mention' : '',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount', });
appIntegrations: 'integrations/getAppIntegrations', });
}),
chatMetadata() { const isHMACVerified = computed(() => {
return this.chat.meta; if (!isAWebWidgetInbox.value) {
}, return true;
backButtonUrl() { }
const { return chatMetadata.value.hmac_verified;
params: { accountId, inbox_id: inboxId, label, teamId }, });
name,
} = this.$route; const currentContact = computed(() =>
return conversationListPageURL({ store.getters['contacts/getContact'](props.chat.meta.sender.id)
accountId, );
inboxId,
label, const isSnoozed = computed(
teamId, () => currentChat.value.status === wootConstants.STATUS_TYPE.SNOOZED
conversationType: name === 'conversation_mentions' ? 'mention' : '', );
});
}, const snoozedDisplayText = computed(() => {
isHMACVerified() { const { snoozed_until: snoozedUntil } = currentChat.value;
if (!this.isAWebWidgetInbox) { if (snoozedUntil) {
return true; return `${t('CONVERSATION.HEADER.SNOOZED_UNTIL')} ${snoozedReopenTime(snoozedUntil)}`;
} }
return this.chatMetadata.hmac_verified; return t('CONVERSATION.HEADER.SNOOZED_UNTIL_NEXT_REPLY');
}, });
currentContact() {
return this.$store.getters['contacts/getContact']( const inbox = computed(() => {
this.chat.meta.sender.id const { inbox_id: inboxId } = props.chat;
); return store.getters['inboxes/getInbox'](inboxId);
}, });
isSnoozed() {
return this.currentChat.status === wootConstants.STATUS_TYPE.SNOOZED; const hasMultipleInboxes = computed(
}, () => store.getters['inboxes/getInboxes'].length > 1
snoozedDisplayText() { );
const { snoozed_until: snoozedUntil } = this.currentChat;
if (snoozedUntil) { const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
return `${this.$t(
'CONVERSATION.HEADER.SNOOZED_UNTIL' const isLinearIntegrationEnabled = computed(() =>
)} ${snoozedReopenTime(snoozedUntil)}`; appIntegrations.value.find(
} integration => integration.id === 'linear' && !!integration.hooks.length
return this.$t('CONVERSATION.HEADER.SNOOZED_UNTIL_NEXT_REPLY'); )
}, );
inbox() {
const { inbox_id: inboxId } = this.chat; const isLinearFeatureEnabled = computed(() =>
return this.$store.getters['inboxes/getInbox'](inboxId); isFeatureEnabledonAccount.value(accountId.value, FEATURE_FLAGS.LINEAR)
}, );
hasMultipleInboxes() {
return this.$store.getters['inboxes/getInboxes'].length > 1;
},
hasSlaPolicyId() {
return this.chat?.sla_policy_id;
},
isLinearIntegrationEnabled() {
return this.appIntegrations.find(
integration => integration.id === 'linear' && !!integration.hooks.length
);
},
isLinearFeatureEnabled() {
return this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.LINEAR
);
},
},
};
</script> </script>
<template> <template>
<div <div
class="flex flex-col items-center justify-between px-3 py-2 border-b bg-n-background border-n-weak md:flex-row h-12" ref="conversationHeader"
class="flex flex-col items-center justify-center flex-1 w-full min-w-0 xl:flex-row px-3 py-2 border-b bg-n-background border-n-weak h-24 xl:h-12"
> >
<div <div
class="flex flex-col items-center justify-center flex-1 w-full min-w-0" class="flex items-center justify-start w-full xl:w-auto max-w-full min-w-0"
:class="isInboxView ? 'sm:flex-row' : 'md:flex-row'"
> >
<div class="flex items-center justify-start max-w-full min-w-0 w-fit"> <BackButton
<BackButton v-if="showBackButton"
v-if="showBackButton" :back-url="backButtonUrl"
:back-url="backButtonUrl" class="ltr:mr-2 rtl:ml-2"
class="ltr:mr-2 rtl:ml-2" />
/> <Thumbnail
<Thumbnail :src="currentContact.thumbnail"
:src="currentContact.thumbnail" :username="currentContact.name"
:badge="inboxBadge" :status="currentContact.availability_status"
:username="currentContact.name" size="32px"
:status="currentContact.availability_status" />
size="32px" <div
/> class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2 w-fit"
<div >
class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2 w-fit" <div class="flex flex-row items-center max-w-full gap-1 p-0 m-0 w-fit">
> <span
<div class="text-sm font-medium truncate leading-tight text-n-slate-12"
class="flex flex-row items-center max-w-full gap-1 p-0 m-0 w-fit"
> >
<NextButton link slate> {{ currentContact.name }}
<span </span>
class="text-sm font-medium truncate leading-tight text-n-slate-12" <fluent-icon
> v-if="!isHMACVerified"
{{ currentContact.name }} v-tooltip="$t('CONVERSATION.UNVERIFIED_SESSION')"
</span> size="14"
</NextButton> class="text-n-amber-10 my-0 mx-0 min-w-[14px]"
<fluent-icon icon="warning"
v-if="!isHMACVerified" />
v-tooltip="$t('CONVERSATION.UNVERIFIED_SESSION')" </div>
size="14"
class="text-n-amber-10 my-0 mx-0 min-w-[14px]"
icon="warning"
/>
</div>
<div <div
class="flex items-center gap-2 overflow-hidden text-xs conversation--header--actions text-ellipsis whitespace-nowrap" class="flex items-center gap-2 overflow-hidden text-xs conversation--header--actions text-ellipsis whitespace-nowrap"
> >
<InboxName v-if="hasMultipleInboxes" :inbox="inbox" /> <InboxName v-if="hasMultipleInboxes" :inbox="inbox" class="!mx-0" />
<span v-if="isSnoozed" class="font-medium text-n-amber-10"> <span v-if="isSnoozed" class="font-medium text-n-amber-10">
{{ snoozedDisplayText }} {{ snoozedDisplayText }}
</span> </span>
</div>
</div> </div>
</div> </div>
<div </div>
class="flex flex-row items-center justify-end flex-grow gap-2 mt-3 header-actions-wrap lg:mt-0" <div
> class="flex flex-row items-center justify-start xl:justify-end flex-grow gap-2 w-full xl:w-auto mt-3 header-actions-wrap xl:mt-0"
<SLACardLabel v-if="hasSlaPolicyId" :chat="chat" show-extended-info /> >
<Linear <SLACardLabel
v-if="isLinearIntegrationEnabled && isLinearFeatureEnabled" v-if="hasSlaPolicyId"
:conversation-id="currentChat.id" :chat="chat"
/> show-extended-info
<MoreActions :conversation-id="currentChat.id" /> :parent-width="width"
</div> class="hidden lg:flex"
/>
<Linear
v-if="isLinearIntegrationEnabled && isLinearFeatureEnabled"
:conversation-id="currentChat.id"
:parent-width="width"
class="hidden lg:flex"
/>
<MoreActions :conversation-id="currentChat.id" />
</div> </div>
</div> </div>
</template> </template>
<style lang="scss" scoped>
.conversation--header--actions {
::v-deep .inbox--name {
@apply m-0;
}
}
</style>
@@ -44,7 +44,7 @@ const activeTab = computed(() => {
<template> <template>
<div <div
class="ltr:border-l rtl:border-r border-n-weak h-full overflow-hidden z-10 w-80 min-w-80 2xl:min-w-96 2xl:w-96 flex flex-col bg-n-background" class="ltr:border-l rtl:border-r border-n-weak h-full overflow-hidden z-10 w-[320px] min-w-[320px] 2xl:min-w-[360px] 2xl:w-[360px] flex flex-col bg-n-background"
> >
<div class="flex flex-1 overflow-auto"> <div class="flex flex-1 overflow-auto">
<ContactPanel <ContactPanel
@@ -1,10 +1,14 @@
<script> <script setup>
import { mapGetters } from 'vuex'; import { computed, onUnmounted } from 'vue';
import { useToggle } from '@vueuse/core';
import { useStore } from 'vuex';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { emitter } from 'shared/helpers/mitt'; import { emitter } from 'shared/helpers/mitt';
import EmailTranscriptModal from './EmailTranscriptModal.vue'; import EmailTranscriptModal from './EmailTranscriptModal.vue';
import ResolveAction from '../../buttons/ResolveAction.vue'; import ResolveAction from '../../buttons/ResolveAction.vue';
import ButtonV4 from 'dashboard/components-next/button/Button.vue'; import ButtonV4 from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import { import {
CMD_MUTE_CONVERSATION, CMD_MUTE_CONVERSATION,
@@ -12,97 +16,111 @@ import {
CMD_UNMUTE_CONVERSATION, CMD_UNMUTE_CONVERSATION,
} from 'dashboard/helper/commandbar/events'; } from 'dashboard/helper/commandbar/events';
export default { // No props needed as we're getting currentChat from the store directly
components: { const store = useStore();
EmailTranscriptModal, const { t } = useI18n();
ResolveAction,
ButtonV4, const [showEmailActionsModal, toggleEmailModal] = useToggle(false);
}, const [showActionsDropdown, toggleDropdown] = useToggle(false);
data() {
return { const currentChat = computed(() => store.getters.getSelectedChat);
showEmailActionsModal: false,
}; const actionMenuItems = computed(() => {
}, const items = [];
computed: {
...mapGetters({ currentChat: 'getSelectedChat' }), if (!currentChat.value.muted) {
}, items.push({
mounted() { icon: 'i-lucide-volume-off',
emitter.on(CMD_MUTE_CONVERSATION, this.mute); label: t('CONTACT_PANEL.MUTE_CONTACT'),
emitter.on(CMD_UNMUTE_CONVERSATION, this.unmute); action: 'mute',
emitter.on(CMD_SEND_TRANSCRIPT, this.toggleEmailActionsModal); value: 'mute',
}, });
unmounted() { } else {
emitter.off(CMD_MUTE_CONVERSATION, this.mute); items.push({
emitter.off(CMD_UNMUTE_CONVERSATION, this.unmute); icon: 'i-lucide-volume-1',
emitter.off(CMD_SEND_TRANSCRIPT, this.toggleEmailActionsModal); label: t('CONTACT_PANEL.UNMUTE_CONTACT'),
}, action: 'unmute',
methods: { value: 'unmute',
mute() { });
this.$store.dispatch('muteConversation', this.currentChat.id); }
useAlert(this.$t('CONTACT_PANEL.MUTED_SUCCESS'));
}, items.push({
unmute() { icon: 'i-lucide-share',
this.$store.dispatch('unmuteConversation', this.currentChat.id); label: t('CONTACT_PANEL.SEND_TRANSCRIPT'),
useAlert(this.$t('CONTACT_PANEL.UNMUTED_SUCCESS')); action: 'send_transcript',
}, value: 'send_transcript',
toggleEmailActionsModal() { });
this.showEmailActionsModal = !this.showEmailActionsModal;
}, return items;
}, });
const handleActionClick = ({ action }) => {
toggleDropdown(false);
if (action === 'mute') {
store.dispatch('muteConversation', currentChat.value.id);
useAlert(t('CONTACT_PANEL.MUTED_SUCCESS'));
} else if (action === 'unmute') {
store.dispatch('unmuteConversation', currentChat.value.id);
useAlert(t('CONTACT_PANEL.UNMUTED_SUCCESS'));
} else if (action === 'send_transcript') {
toggleEmailModal();
}
}; };
// These functions are needed for the event listeners
const mute = () => {
store.dispatch('muteConversation', currentChat.value.id);
useAlert(t('CONTACT_PANEL.MUTED_SUCCESS'));
};
const unmute = () => {
store.dispatch('unmuteConversation', currentChat.value.id);
useAlert(t('CONTACT_PANEL.UNMUTED_SUCCESS'));
};
emitter.on(CMD_MUTE_CONVERSATION, mute);
emitter.on(CMD_UNMUTE_CONVERSATION, unmute);
emitter.on(CMD_SEND_TRANSCRIPT, toggleEmailModal);
onUnmounted(() => {
emitter.off(CMD_MUTE_CONVERSATION, mute);
emitter.off(CMD_UNMUTE_CONVERSATION, unmute);
emitter.off(CMD_SEND_TRANSCRIPT, toggleEmailModal);
});
</script> </script>
<template> <template>
<div class="relative flex items-center gap-2 actions--container"> <div class="relative flex items-center gap-2 actions--container">
<ButtonV4
v-if="!currentChat.muted"
v-tooltip="$t('CONTACT_PANEL.MUTE_CONTACT')"
size="sm"
variant="ghost"
color="slate"
icon="i-lucide-volume-off"
@click="mute"
/>
<ButtonV4
v-else
v-tooltip.left="$t('CONTACT_PANEL.UNMUTE_CONTACT')"
size="sm"
variant="ghost"
color="slate"
icon="i-lucide-volume-1"
@click="unmute"
/>
<ButtonV4
v-tooltip="$t('CONTACT_PANEL.SEND_TRANSCRIPT')"
size="sm"
variant="ghost"
color="slate"
icon="i-lucide-share"
@click="toggleEmailActionsModal"
/>
<ResolveAction <ResolveAction
:conversation-id="currentChat.id" :conversation-id="currentChat.id"
:status="currentChat.status" :status="currentChat.status"
/> />
<div
v-on-clickaway="() => toggleDropdown(false)"
class="relative flex items-center group"
>
<ButtonV4
v-tooltip="$t('CONVERSATION.HEADER.MORE_ACTIONS')"
size="sm"
variant="ghost"
color="slate"
icon="i-lucide-more-vertical"
class="rounded-md group-hover:bg-n-alpha-2"
@click="toggleDropdown()"
/>
<DropdownMenu
v-if="showActionsDropdown"
:menu-items="actionMenuItems"
class="mt-1 ltr:right-0 rtl:left-0 top-full"
@action="handleActionClick"
/>
</div>
<EmailTranscriptModal <EmailTranscriptModal
v-if="showEmailActionsModal" v-if="showEmailActionsModal"
:show="showEmailActionsModal" :show="showEmailActionsModal"
:current-chat="currentChat" :current-chat="currentChat"
@cancel="toggleEmailActionsModal" @cancel="toggleEmailModal"
/> />
</div> </div>
</template> </template>
<style scoped lang="scss">
.more--button {
@apply items-center flex ml-2 rtl:ml-0 rtl:mr-2;
}
.dropdown-pane {
@apply -right-2 top-12;
}
.icon {
@apply mr-1 rtl:mr-0 rtl:ml-1 min-w-[1rem];
}
</style>
@@ -1,132 +1,115 @@
<script> <script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { evaluateSLAStatus } from '@chatwoot/utils'; import { evaluateSLAStatus } from '@chatwoot/utils';
import SLAPopoverCard from './SLAPopoverCard.vue'; import SLAPopoverCard from './SLAPopoverCard.vue';
const props = defineProps({
chat: {
type: Object,
default: () => ({}),
},
showExtendedInfo: {
type: Boolean,
default: false,
},
parentWidth: {
type: Number,
default: 1000,
},
});
const REFRESH_INTERVAL = 60000; const REFRESH_INTERVAL = 60000;
const { t } = useI18n();
export default { const timer = ref(null);
components: { const slaStatus = ref({
SLAPopoverCard, threshold: null,
}, isSlaMissed: false,
props: { type: null,
chat: { icon: null,
type: Object, });
default: () => ({}),
},
showExtendedInfo: {
type: Boolean,
default: false,
},
},
data() {
return {
timer: null,
showSlaPopover: false,
slaStatus: {
threshold: null,
isSlaMissed: false,
type: null,
icon: null,
},
};
},
computed: {
slaPolicyId() {
return this.chat?.sla_policy_id;
},
appliedSLA() {
return this.chat?.applied_sla;
},
slaEvents() {
return this.chat?.sla_events;
},
hasSlaThreshold() {
return this.slaStatus?.threshold;
},
isSlaMissed() {
return this.slaStatus?.isSlaMissed;
},
slaTextStyles() {
return this.isSlaMissed ? 'text-n-ruby-11' : 'text-n-amber-11';
},
slaStatusText() {
const upperCaseType = this.slaStatus?.type?.toUpperCase(); // FRT, NRT, or RT
const statusKey = this.isSlaMissed ? 'MISSED' : 'DUE';
return this.$t(`CONVERSATION.HEADER.SLA_STATUS.${upperCaseType}`, { const appliedSLA = computed(() => props.chat?.applied_sla);
status: this.$t(`CONVERSATION.HEADER.SLA_STATUS.${statusKey}`), const slaEvents = computed(() => props.chat?.sla_events);
}); const hasSlaThreshold = computed(() => slaStatus.value?.threshold);
}, const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
showSlaPopoverCard() { const slaTextStyles = computed(() =>
return ( isSlaMissed.value ? 'text-n-ruby-11' : 'text-n-amber-11'
this.showExtendedInfo && this.showSlaPopover && this.slaEvents.length );
);
}, const slaStatusText = computed(() => {
}, const upperCaseType = slaStatus.value?.type?.toUpperCase(); // FRT, NRT, or RT
watch: { const statusKey = isSlaMissed.value ? 'MISSED' : 'DUE';
chat() {
this.updateSlaStatus(); return t(`CONVERSATION.HEADER.SLA_STATUS.${upperCaseType}`, {
}, status: t(`CONVERSATION.HEADER.SLA_STATUS.${statusKey}`),
}, });
mounted() { });
this.updateSlaStatus();
this.createTimer(); const showSlaPopoverCard = computed(
}, () => props.showExtendedInfo && slaEvents.value?.length > 0
unmounted() { );
if (this.timer) {
clearTimeout(this.timer); const groupClass = computed(() => {
} return props.showExtendedInfo
}, ? 'h-[26px] rounded-lg bg-n-alpha-1'
methods: { : 'rounded h-5 border border-n-strong';
createTimer() { });
this.timer = setTimeout(() => {
this.updateSlaStatus(); const updateSlaStatus = () => {
this.createTimer(); slaStatus.value = evaluateSLAStatus({
}, REFRESH_INTERVAL); appliedSla: appliedSLA.value,
}, chat: props.chat,
updateSlaStatus() { });
this.slaStatus = evaluateSLAStatus({
appliedSla: this.appliedSLA,
chat: this.chat,
});
},
openSlaPopover() {
if (!this.showExtendedInfo) return;
this.showSlaPopover = true;
},
closeSlaPopover() {
this.showSlaPopover = false;
},
},
}; };
const createTimer = () => {
timer.value = setTimeout(() => {
updateSlaStatus();
createTimer();
}, REFRESH_INTERVAL);
};
watch(
() => props.chat,
() => {
updateSlaStatus();
}
);
const slaPopoverClass = computed(() => {
return props.showExtendedInfo
? 'ltr:pr-1.5 rtl:pl-1.5 ltr:border-r rtl:border-l border-n-strong'
: '';
});
onMounted(() => {
updateSlaStatus();
createTimer();
});
onUnmounted(() => {
if (timer.value) {
clearTimeout(timer.value);
}
});
</script> </script>
<!-- eslint-disable-next-line vue/no-root-v-if --> <!-- eslint-disable-next-line vue/no-root-v-if -->
<template> <template>
<div <div
v-if="hasSlaThreshold" v-if="hasSlaThreshold"
class="relative flex items-center cursor-pointer min-w-fit" class="relative flex items-center cursor-pointer min-w-fit group"
:class=" :class="groupClass"
showExtendedInfo
? 'h-[26px] rounded-lg bg-n-alpha-1'
: 'rounded h-5 border border-n-strong'
"
> >
<div <div
v-on-clickaway="closeSlaPopover" class="flex items-center w-full truncate px-1.5"
class="flex items-center w-full truncate" :class="showExtendedInfo ? '' : 'gap-1'"
:class="showExtendedInfo ? 'px-1.5' : 'px-2 gap-1'"
@mouseover="openSlaPopover()"
> >
<div <div class="flex items-center gap-1" :class="slaPopoverClass">
class="flex items-center gap-1"
:class="
showExtendedInfo &&
'ltr:pr-1.5 rtl:pl-1.5 ltr:border-r rtl:border-l border-n-strong'
"
>
<fluent-icon <fluent-icon
size="14" size="12"
:icon="slaStatus.icon" :icon="slaStatus.icon"
type="outline" type="outline"
:icon-lib="isSlaMissed ? 'lucide' : 'fluent'" :icon-lib="isSlaMissed ? 'lucide' : 'fluent'"
@@ -134,7 +117,7 @@ export default {
:class="slaTextStyles" :class="slaTextStyles"
/> />
<span <span
v-if="showExtendedInfo" v-if="showExtendedInfo && parentWidth > 650"
class="text-xs font-medium" class="text-xs font-medium"
:class="slaTextStyles" :class="slaTextStyles"
> >
@@ -151,7 +134,7 @@ export default {
<SLAPopoverCard <SLAPopoverCard
v-if="showSlaPopoverCard" v-if="showSlaPopoverCard"
:sla-missed-events="slaEvents" :sla-missed-events="slaEvents"
class="right-0 top-7" class="rtl:left-0 ltr:right-0 top-7 hidden group-hover:flex"
/> />
</div> </div>
</template> </template>
@@ -16,6 +16,10 @@ const props = defineProps({
type: [Number, String], type: [Number, String],
required: true, required: true,
}, },
parentWidth: {
type: Number,
default: 10000,
},
}); });
defineOptions({ defineOptions({
@@ -73,6 +77,14 @@ const unlinkIssue = async linkId => {
} }
}; };
const shouldShowIssueIdentifier = computed(() => {
if (!linkedIssue.value) {
return false;
}
return props.parentWidth > 600;
});
const openIssue = () => { const openIssue = () => {
if (!linkedIssue.value) shouldShowPopup.value = true; if (!linkedIssue.value) shouldShowPopup.value = true;
shouldShow.value = true; shouldShow.value = true;
@@ -119,7 +131,10 @@ onMounted(() => {
class="text-[#5E6AD2] flex-shrink-0" class="text-[#5E6AD2] flex-shrink-0"
view-box="0 0 19 19" view-box="0 0 19 19"
/> />
<span v-if="linkedIssue" class="text-xs font-medium text-n-slate-11"> <span
v-if="shouldShowIssueIdentifier"
class="text-xs font-medium text-n-slate-11"
>
{{ linkedIssue.issue.identifier }} {{ linkedIssue.issue.identifier }}
</span> </span>
</Button> </Button>
@@ -127,7 +142,7 @@ onMounted(() => {
v-if="linkedIssue" v-if="linkedIssue"
:issue="linkedIssue.issue" :issue="linkedIssue.issue"
:link-id="linkedIssue.id" :link-id="linkedIssue.id"
class="absolute right-0 top-[36px] invisible group-hover:visible" class="absolute rtl:left-0 ltr:right-0 top-9 invisible group-hover:visible"
@unlink-issue="unlinkIssue" @unlink-issue="unlinkIssue"
/> />
<woot-modal <woot-modal
@@ -1,110 +0,0 @@
import { mount } from '@vue/test-utils';
import { createStore } from 'vuex';
import MoreActions from '../MoreActions.vue';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue';
vi.mock('shared/helpers/mitt', () => ({
emitter: {
emit: vi.fn(),
on: vi.fn(),
off: vi.fn(),
},
}));
const mockDirective = {
mounted: () => {},
};
import { emitter } from 'shared/helpers/mitt';
describe('MoveActions', () => {
let currentChat = { id: 8, muted: false };
let store = null;
let muteConversation = null;
let unmuteConversation = null;
beforeEach(() => {
muteConversation = vi.fn(() => Promise.resolve());
unmuteConversation = vi.fn(() => Promise.resolve());
store = createStore({
state: {
authenticated: true,
currentChat,
},
getters: {
getSelectedChat: () => currentChat,
},
modules: {
conversations: {
namespaced: false,
actions: { muteConversation, unmuteConversation },
},
},
});
});
const createWrapper = () =>
mount(MoreActions, {
global: {
plugins: [store],
components: {
'fluent-icon': FluentIcon,
},
directives: {
'on-clickaway': mockDirective,
},
},
});
describe('muting discussion', () => {
it('triggers "muteConversation"', async () => {
const wrapper = createWrapper();
await wrapper.find('button:first-child').trigger('click');
expect(muteConversation).toHaveBeenCalledTimes(1);
expect(muteConversation).toHaveBeenCalledWith(
expect.any(Object), // First argument is the Vuex context object
currentChat.id // Second argument is the ID of the conversation
);
});
it('shows alert', async () => {
const wrapper = createWrapper();
await wrapper.find('button:first-child').trigger('click');
expect(emitter.emit).toBeCalledWith('newToastMessage', {
message:
'This contact is blocked successfully. You will not be notified of any future conversations.',
action: null,
});
});
});
describe('unmuting discussion', () => {
beforeEach(() => {
currentChat.muted = true;
});
it('triggers "unmuteConversation"', async () => {
const wrapper = createWrapper();
await wrapper.find('button:first-child').trigger('click');
expect(unmuteConversation).toHaveBeenCalledTimes(1);
expect(unmuteConversation).toHaveBeenCalledWith(
expect.any(Object), // First argument is the Vuex context object
currentChat.id // Second argument is the ID of the conversation
);
});
it('shows alert', async () => {
const wrapper = createWrapper();
await wrapper.find('button:first-child').trigger('click');
expect(emitter.emit).toBeCalledWith('newToastMessage', {
message: 'This contact is unblocked successfully.',
action: null,
});
});
});
});
@@ -43,7 +43,7 @@ describe('useFontSize', () => {
it('returns fontSizeOptions with correct structure', () => { it('returns fontSizeOptions with correct structure', () => {
const { fontSizeOptions } = useFontSize(); const { fontSizeOptions } = useFontSize();
expect(fontSizeOptions).toHaveLength(6); expect(fontSizeOptions).toHaveLength(5);
expect(fontSizeOptions[0]).toHaveProperty('value'); expect(fontSizeOptions[0]).toHaveProperty('value');
expect(fontSizeOptions[0]).toHaveProperty('label'); expect(fontSizeOptions[0]).toHaveProperty('label');
@@ -59,12 +59,6 @@ describe('useFontSize', () => {
label: label:
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.SMALLER', 'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.SMALLER',
}); });
expect(fontSizeOptions.find(option => option.value === '22px')).toEqual({
value: '22px',
label:
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.EXTRA_LARGE',
});
}); });
it('returns currentFontSize from UI settings', () => { it('returns currentFontSize from UI settings', () => {
@@ -84,9 +78,6 @@ describe('useFontSize', () => {
applyFontSize('14px'); applyFontSize('14px');
expect(document.documentElement.style.fontSize).toBe('14px'); expect(document.documentElement.style.fontSize).toBe('14px');
applyFontSize('22px');
expect(document.documentElement.style.fontSize).toBe('22px');
applyFontSize('16px'); applyFontSize('16px');
expect(document.documentElement.style.fontSize).toBe('16px'); expect(document.documentElement.style.fontSize).toBe('16px');
}); });
@@ -145,8 +136,6 @@ describe('useFontSize', () => {
'Smaller', 'Smaller',
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.DEFAULT': 'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.DEFAULT':
'Default', 'Default',
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.EXTRA_LARGE':
'Extra Large',
}; };
return translations[key] || key; return translations[key] || key;
}); });
@@ -160,9 +149,6 @@ describe('useFontSize', () => {
expect(fontSizeOptions.find(option => option.value === '16px').label).toBe( expect(fontSizeOptions.find(option => option.value === '16px').label).toBe(
'Default' 'Default'
); );
expect(fontSizeOptions.find(option => option.value === '22px').label).toBe(
'Extra Large'
);
// Verify translation function was called with correct keys // Verify translation function was called with correct keys
expect(mockTranslate).toHaveBeenCalledWith( expect(mockTranslate).toHaveBeenCalledWith(
@@ -19,7 +19,6 @@ const FONT_SIZE_OPTIONS = {
DEFAULT: '16px', DEFAULT: '16px',
LARGE: '18px', LARGE: '18px',
LARGER: '20px', LARGER: '20px',
EXTRA_LARGE: '22px',
}; };
/** /**
@@ -129,6 +129,7 @@ export function usePolicy() {
return { return {
checkPermissions, checkPermissions,
shouldShowPaywall, shouldShowPaywall,
isFeatureFlagEnabled,
shouldShow, shouldShow,
}; };
} }
@@ -62,7 +62,9 @@
"ACCESS_TOKEN": { "ACCESS_TOKEN": {
"TITLE": "Access Token", "TITLE": "Access Token",
"DESCRIPTION": "Copy the access token and save it securely", "DESCRIPTION": "Copy the access token and save it securely",
"COPY_SUCCESSFUL": "Access token copied to clipboard" "COPY_SUCCESSFUL": "Access token copied to clipboard",
"RESET_SUCCESS": "Access token regenerated successfully",
"RESET_ERROR": "Unable to regenerate access token. Please try again"
}, },
"FORM": { "FORM": {
"AVATAR": { "AVATAR": {
@@ -70,6 +70,7 @@
"RESOLVE_ACTION": "Resolve", "RESOLVE_ACTION": "Resolve",
"REOPEN_ACTION": "Reopen", "REOPEN_ACTION": "Reopen",
"OPEN_ACTION": "Open", "OPEN_ACTION": "Open",
"MORE_ACTIONS": "More actions",
"OPEN": "More", "OPEN": "More",
"CLOSE": "Close", "CLOSE": "Close",
"DETAILS": "details", "DETAILS": "details",
@@ -76,7 +76,12 @@
"ACCESS_TOKEN": { "ACCESS_TOKEN": {
"TITLE": "Access Token", "TITLE": "Access Token",
"NOTE": "This token can be used if you are building an API based integration", "NOTE": "This token can be used if you are building an API based integration",
"COPY": "Copy" "COPY": "Copy",
"RESET": "Reset",
"CONFIRM_RESET": "Are you sure?",
"CONFIRM_HINT": "Click again to confirm",
"RESET_SUCCESS": "Access token regenerated successfully",
"RESET_ERROR": "Unable to regenerate access token. Please try again"
}, },
"AUDIO_NOTIFICATIONS_SECTION": { "AUDIO_NOTIFICATIONS_SECTION": {
"TITLE": "Audio Alerts", "TITLE": "Audio Alerts",
@@ -10,10 +10,8 @@ import {
CONTACT_PERMISSIONS, CONTACT_PERMISSIONS,
PORTAL_PERMISSIONS, PORTAL_PERMISSIONS,
} from 'dashboard/constants/permissions.js'; } from 'dashboard/constants/permissions.js';
import { import { usePolicy } from 'dashboard/composables/usePolicy';
getUserPermissions, import { FEATURE_FLAGS } from 'dashboard/featureFlags';
filterItemsByPermission,
} from 'dashboard/helper/permissionsHelper.js';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events'; import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
import Policy from 'dashboard/components/policy.vue'; import Policy from 'dashboard/components/policy.vue';
@@ -39,8 +37,6 @@ const pages = ref({
articles: 1, articles: 1,
}); });
const currentUser = useMapGetter('getCurrentUser');
const currentAccountId = useMapGetter('getCurrentAccountId');
const contactRecords = useMapGetter('conversationSearch/getContactRecords'); const contactRecords = useMapGetter('conversationSearch/getContactRecords');
const conversationRecords = useMapGetter( const conversationRecords = useMapGetter(
'conversationSearch/getConversationRecords' 'conversationSearch/getConversationRecords'
@@ -83,9 +79,7 @@ const filterConversations = filterByTab('conversations');
const filterMessages = filterByTab('messages'); const filterMessages = filterByTab('messages');
const filterArticles = filterByTab('articles'); const filterArticles = filterByTab('articles');
const userPermissions = computed(() => const { shouldShow, isFeatureFlagEnabled } = usePolicy();
getUserPermissions(currentUser.value, currentAccountId.value)
);
const TABS_CONFIG = { const TABS_CONFIG = {
all: { all: {
@@ -111,47 +105,67 @@ const TABS_CONFIG = {
}, },
articles: { articles: {
permissions: [...ROLES, PORTAL_PERMISSIONS], permissions: [...ROLES, PORTAL_PERMISSIONS],
featureFlag: FEATURE_FLAGS.HELP_CENTER,
count: () => mappedArticles.value.length, count: () => mappedArticles.value.length,
}, },
}; };
const tabs = computed(() => { const tabs = computed(() => {
const configs = Object.entries(TABS_CONFIG).map(([key, config]) => ({ return Object.entries(TABS_CONFIG)
key, .map(([key, config]) => ({
name: t(`SEARCH.TABS.${key.toUpperCase()}`), key,
count: config.count(), name: t(`SEARCH.TABS.${key.toUpperCase()}`),
showBadge: key !== 'all', count: config.count(),
permissions: config.permissions, showBadge: key !== 'all',
})); permissions: config.permissions,
featureFlag: config.featureFlag,
return filterItemsByPermission( }))
configs, .filter(config => {
userPermissions.value, // why the double check, glad you asked.
item => item.permissions // Some features are marked as premium features, that means
); // the feature will be visible, but a Paywall will be shown instead
// this works for pages and routes, but fails for UI elements like search here
// so we explicitly check if the feature is enabled
return (
shouldShow(config.featureFlag, config.permissions, null) &&
isFeatureFlagEnabled(config.featureFlag)
);
});
}); });
const totalSearchResultsCount = computed(() => { const totalSearchResultsCount = computed(() => {
const permissionCounts = { const permissionCounts = [
contacts: { {
permissions: [...ROLES, CONTACT_PERMISSIONS], permissions: [...ROLES, CONTACT_PERMISSIONS],
count: () => contacts.value.length, count: () => contacts.value.length,
}, },
conversations: { {
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS], permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
count: () => conversations.value.length + messages.value.length, count: () => conversations.value.length + messages.value.length,
}, },
articles: { {
permissions: [...ROLES, PORTAL_PERMISSIONS], permissions: [...ROLES, PORTAL_PERMISSIONS],
featureFlag: FEATURE_FLAGS.HELP_CENTER,
count: () => articles.value.length, count: () => articles.value.length,
}, },
}; ];
return filterItemsByPermission(
permissionCounts, return permissionCounts
userPermissions.value, .filter(config => {
item => item.permissions, // why the double check, glad you asked.
(_, item) => item.count // Some features are marked as premium features, that means
).reduce((total, count) => total + count(), 0); // the feature will be visible, but a Paywall will be shown instead
// this works for pages and routes, but fails for UI elements like search here
// so we explicitly check if the feature is enabled
return (
shouldShow(config.featureFlag, config.permissions, null) &&
isFeatureFlagEnabled(config.featureFlag)
);
})
.map(config => {
return config.count();
})
.reduce((sum, count) => sum + count, 0);
}); });
const activeTabIndex = computed(() => { const activeTabIndex = computed(() => {
@@ -355,7 +369,9 @@ onUnmounted(() => {
</Policy> </Policy>
<Policy <Policy
v-if="isFeatureFlagEnabled(FEATURE_FLAGS.HELP_CENTER)"
:permissions="[...ROLES, PORTAL_PERMISSIONS]" :permissions="[...ROLES, PORTAL_PERMISSIONS]"
:feature-flag="FEATURE_FLAGS.HELP_CENTER"
class="flex flex-col justify-center" class="flex flex-col justify-center"
> >
<SearchResultArticlesList <SearchResultArticlesList
@@ -228,7 +228,20 @@ const initializeForm = () => {
const onCopyToken = async value => { const onCopyToken = async value => {
await copyTextToClipboard(value); await copyTextToClipboard(value);
useAlert(t('COMPONENTS.CODE.COPY_SUCCESSFUL')); useAlert(t('AGENT_BOTS.ACCESS_TOKEN.COPY_SUCCESSFUL'));
};
const onResetToken = async () => {
const response = await store.dispatch(
'agentBots/resetAccessToken',
props.selectedBot.id
);
if (response) {
accessToken.value = response.access_token;
useAlert(t('AGENT_BOTS.ACCESS_TOKEN.RESET_SUCCESS'));
} else {
useAlert(t('AGENT_BOTS.ACCESS_TOKEN.RESET_ERROR'));
}
}; };
const closeModal = () => { const closeModal = () => {
@@ -312,7 +325,18 @@ defineExpose({ dialogRef });
> >
{{ $t('AGENT_BOTS.ACCESS_TOKEN.TITLE') }} {{ $t('AGENT_BOTS.ACCESS_TOKEN.TITLE') }}
</label> </label>
<AccessToken :value="accessToken" @on-copy="onCopyToken" /> <AccessToken
v-if="type === MODAL_TYPES.EDIT"
:value="accessToken"
@on-copy="onCopyToken"
@on-reset="onResetToken"
/>
<AccessToken
v-else
:value="accessToken"
:show-reset-button="false"
@on-copy="onCopyToken"
/>
</div> </div>
<div class="flex items-center justify-end w-full gap-2 px-0 py-2"> <div class="flex items-center justify-end w-full gap-2 px-0 py-2">
@@ -1,17 +1,21 @@
<script setup> <script setup>
import { ref, computed } from 'vue'; import { ref, computed, onMounted } from 'vue';
import ForwardToOption from './emailChannels/ForwardToOption.vue'; import ForwardToOption from './emailChannels/ForwardToOption.vue';
import Microsoft from './emailChannels/Microsoft.vue'; import Microsoft from './emailChannels/Microsoft.vue';
import Google from './emailChannels/Google.vue'; import Google from './emailChannels/Google.vue';
import ChannelSelector from 'dashboard/components/ChannelSelector.vue'; import ChannelSelector from 'dashboard/components/ChannelSelector.vue';
import PageHeader from '../../SettingsSubPageHeader.vue'; import PageHeader from '../../SettingsSubPageHeader.vue';
import { useAlert } from 'dashboard/composables';
import { useStoreGetters } from 'dashboard/composables/store'; import { useStoreGetters } from 'dashboard/composables/store';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
const provider = ref(''); const provider = ref('');
const getters = useStoreGetters(); const getters = useStoreGetters();
const route = useRoute();
const { t } = useI18n(); const { t } = useI18n();
const globalConfig = getters['globalConfig/get']; const globalConfig = getters['globalConfig/get'];
@@ -45,6 +49,15 @@ const emailProviderList = computed(() => {
}); });
}); });
const checkForOAuthError = () => {
const { error } = route.query;
if (error) {
useAlert(decodeURIComponent(error));
}
};
onMounted(checkForOAuthError);
function onClick(emailProvider) { function onClick(emailProvider) {
if (emailProvider.isEnabled) { if (emailProvider.isEnabled) {
provider.value = emailProvider.key; provider.value = emailProvider.key;
@@ -12,7 +12,6 @@ defineOptions({
provider="google" provider="google"
:title="$t('INBOX_MGMT.ADD.GOOGLE.TITLE')" :title="$t('INBOX_MGMT.ADD.GOOGLE.TITLE')"
:description="$t('INBOX_MGMT.ADD.GOOGLE.DESCRIPTION')" :description="$t('INBOX_MGMT.ADD.GOOGLE.DESCRIPTION')"
:input-placeholder="$t('INBOX_MGMT.ADD.GOOGLE.EMAIL_PLACEHOLDER')"
:submit-button-text="$t('INBOX_MGMT.ADD.GOOGLE.SIGN_IN')" :submit-button-text="$t('INBOX_MGMT.ADD.GOOGLE.SIGN_IN')"
:error-message="$t('INBOX_MGMT.ADD.GOOGLE.ERROR_MESSAGE')" :error-message="$t('INBOX_MGMT.ADD.GOOGLE.ERROR_MESSAGE')"
/> />
@@ -12,7 +12,6 @@ defineOptions({
provider="microsoft" provider="microsoft"
:title="$t('INBOX_MGMT.ADD.MICROSOFT.TITLE')" :title="$t('INBOX_MGMT.ADD.MICROSOFT.TITLE')"
:description="$t('INBOX_MGMT.ADD.MICROSOFT.DESCRIPTION')" :description="$t('INBOX_MGMT.ADD.MICROSOFT.DESCRIPTION')"
:input-placeholder="$t('INBOX_MGMT.ADD.MICROSOFT.EMAIL_PLACEHOLDER')"
:submit-button-text="$t('INBOX_MGMT.ADD.MICROSOFT.SIGN_IN')" :submit-button-text="$t('INBOX_MGMT.ADD.MICROSOFT.SIGN_IN')"
:error-message="$t('INBOX_MGMT.ADD.MICROSOFT.ERROR_MESSAGE')" :error-message="$t('INBOX_MGMT.ADD.MICROSOFT.ERROR_MESSAGE')"
/> />
@@ -30,14 +30,9 @@ const props = defineProps({
type: String, type: String,
required: true, required: true,
}, },
inputPlaceholder: {
type: String,
required: true,
},
}); });
const isRequestingAuthorization = ref(false); const isRequestingAuthorization = ref(false);
const email = ref('');
const client = computed(() => { const client = computed(() => {
if (props.provider === 'microsoft') { if (props.provider === 'microsoft') {
@@ -50,9 +45,7 @@ const client = computed(() => {
async function requestAuthorization() { async function requestAuthorization() {
try { try {
isRequestingAuthorization.value = true; isRequestingAuthorization.value = true;
const response = await client.value.generateAuthorization({ const response = await client.value.generateAuthorization();
email: email.value,
});
const { const {
data: { url }, data: { url },
} = response; } = response;
@@ -75,11 +68,6 @@ async function requestAuthorization() {
:header-content="description" :header-content="description"
/> />
<form class="mt-6" @submit.prevent="requestAuthorization"> <form class="mt-6" @submit.prevent="requestAuthorization">
<woot-input
v-model="email"
type="email"
:placeholder="inputPlaceholder"
/>
<NextButton <NextButton
:is-loading="isRequestingAuthorization" :is-loading="isRequestingAuthorization"
type="submit" type="submit"
@@ -1,35 +1,19 @@
<script setup> <script setup>
import { ref, computed } from 'vue'; import { ref } from 'vue';
import InboxReconnectionRequired from '../../components/InboxReconnectionRequired.vue'; import InboxReconnectionRequired from '../../components/InboxReconnectionRequired.vue';
import googleClient from 'dashboard/api/channel/googleClient'; import googleClient from 'dashboard/api/channel/googleClient';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
const props = defineProps({
inbox: {
type: Object,
default: () => ({}),
},
});
const { t } = useI18n(); const { t } = useI18n();
const isRequestingAuthorization = ref(false); const isRequestingAuthorization = ref(false);
const inboxEmail = computed(() => {
if (props.inbox.imap_login && props.inbox.imap_enabled) {
return props.inbox.imap_login;
}
return props.inbox.email;
});
async function requestAuthorization() { async function requestAuthorization() {
try { try {
isRequestingAuthorization.value = true; isRequestingAuthorization.value = true;
const response = await googleClient.generateAuthorization({ const response = await googleClient.generateAuthorization();
email: inboxEmail.value,
});
const { const {
data: { url }, data: { url },
@@ -6,13 +6,6 @@ import microsoftClient from 'dashboard/api/channel/microsoftClient';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
const props = defineProps({
inbox: {
type: Object,
default: () => ({}),
},
});
const { t } = useI18n(); const { t } = useI18n();
const isRequestingAuthorization = ref(false); const isRequestingAuthorization = ref(false);
@@ -20,9 +13,7 @@ const isRequestingAuthorization = ref(false);
async function requestAuthorization() { async function requestAuthorization() {
try { try {
isRequestingAuthorization.value = true; isRequestingAuthorization.value = true;
const response = await microsoftClient.generateAuthorization({ const response = await microsoftClient.generateAuthorization();
email: props.inbox.email,
});
const { const {
data: { url }, data: { url },
@@ -1,14 +1,17 @@
<script setup> <script setup>
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import FormButton from 'v3/components/Form/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
import ConfirmButton from 'dashboard/components-next/button/ConfirmButton.vue';
const props = defineProps({ const props = defineProps({
value: { value: { type: String, default: '' },
type: String, showResetButton: { type: Boolean, default: true },
default: '',
},
}); });
const emit = defineEmits(['onCopy']);
const emit = defineEmits(['onCopy', 'onReset']);
const inputType = ref('password'); const inputType = ref('password');
const toggleMasked = () => { const toggleMasked = () => {
inputType.value = inputType.value === 'password' ? 'text' : 'password'; inputType.value = inputType.value === 'password' ? 'text' : 'password';
}; };
@@ -20,6 +23,10 @@ const maskIcon = computed(() => {
const onClick = () => { const onClick = () => {
emit('onCopy', props.value); emit('onCopy', props.value);
}; };
const onReset = () => {
emit('onReset');
};
</script> </script>
<template> <template>
@@ -38,7 +45,7 @@ const onClick = () => {
> >
<template #masked> <template #masked>
<button <button
class="absolute top-1.5 ltr:right-0.5 rtl:left-0.5" class="absolute top-0 bottom-0 ltr:right-0.5 rtl:left-0.5"
type="button" type="button"
@click="toggleMasked" @click="toggleMasked"
> >
@@ -46,15 +53,28 @@ const onClick = () => {
</button> </button>
</template> </template>
</woot-input> </woot-input>
<FormButton <div class="flex flex-row gap-2">
type="button" <NextButton
size="large" :label="$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.COPY')"
icon="text-copy" slate
variant="outline" outline
color-scheme="secondary" type="button"
@click="onClick" icon="i-lucide-copy"
> class="rounded-xl"
{{ $t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.COPY') }} @click="onClick"
</FormButton> />
<ConfirmButton
v-if="showResetButton"
:label="$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.RESET')"
:confirm-label="$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.CONFIRM_RESET')"
:confirm-hint="$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.CONFIRM_HINT')"
color="slate"
confirm-color="ruby"
variant="outline"
icon="i-lucide-key-round"
class="rounded-xl"
@click="onReset"
/>
</div>
</div> </div>
</template> </template>
@@ -181,6 +181,14 @@ export default {
await copyTextToClipboard(value); await copyTextToClipboard(value);
useAlert(this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL')); useAlert(this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
}, },
async resetAccessToken() {
const success = await this.$store.dispatch('resetAccessToken');
if (success) {
useAlert(this.$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.RESET_SUCCESS'));
} else {
useAlert(this.$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.RESET_ERROR'));
}
},
}, },
}; };
</script> </script>
@@ -237,7 +245,12 @@ export default {
<button <button
v-for="hotKey in hotKeys" v-for="hotKey in hotKeys"
:key="hotKey.key" :key="hotKey.key"
class="px-0 reset-base w-full sm:flex-1" class="px-0 reset-base w-full sm:flex-1 rounded-xl outline-1 outline"
:class="
isEditorHotKeyEnabled(hotKey.key)
? 'outline-n-brand/30'
: 'outline-n-weak'
"
> >
<HotKeyCard <HotKeyCard
:key="hotKey.title" :key="hotKey.title"
@@ -281,7 +294,11 @@ export default {
) )
" "
> >
<AccessToken :value="currentUser.access_token" @on-copy="onCopyToken" /> <AccessToken
:value="currentUser.access_token"
@on-copy="onCopyToken"
@on-reset="resetAccessToken"
/>
</FormSection> </FormSection>
</div> </div>
</template> </template>
@@ -1,4 +1,6 @@
<script setup> <script setup>
import { computed } from 'vue';
const { row } = defineProps({ const { row } = defineProps({
row: { row: {
type: Object, type: Object,
@@ -6,10 +8,10 @@ const { row } = defineProps({
}, },
}); });
const routerParams = { const routerParams = computed(() => ({
name: 'inbox_conversation', name: 'inbox_conversation',
params: { conversation_id: row.original.conversationId }, params: { conversation_id: row.original.conversationId },
}; }));
</script> </script>
<template> <template>
@@ -47,14 +47,17 @@ const tableData = computed(() => {
})); }));
}); });
const defaulSpanRender = cellProps => const defaultSpanRender = cellProps => {
h( const value = cellProps.getValue() || '---';
return h(
'span', 'span',
{ {
class: cellProps.getValue() ? '' : 'text-slate-300 dark:text-slate-700', class: 'line-clamp-5 break-words max-w-full text-n-slate-12',
title: value,
}, },
cellProps.getValue() ? cellProps.getValue() : '---' value
); );
};
const columnHelper = createColumnHelper(); const columnHelper = createColumnHelper();
@@ -65,7 +68,10 @@ const columns = [
cell: cellProps => { cell: cellProps => {
const { contact } = cellProps.row.original; const { contact } = cellProps.row.original;
if (contact) { if (contact) {
return h(UserAvatarWithName, { user: contact }); return h(UserAvatarWithName, {
user: contact,
class: 'max-w-[200px] overflow-hidden',
});
} }
return '--'; return '--';
}, },
@@ -76,7 +82,10 @@ const columns = [
cell: cellProps => { cell: cellProps => {
const { assignedAgent } = cellProps.row.original; const { assignedAgent } = cellProps.row.original;
if (assignedAgent) { if (assignedAgent) {
return h(UserAvatarWithName, { user: assignedAgent }); return h(UserAvatarWithName, {
user: assignedAgent,
class: 'max-w-[200px] overflow-hidden',
});
} }
return '--'; return '--';
}, },
@@ -105,7 +114,7 @@ const columns = [
columnHelper.accessor('feedbackText', { columnHelper.accessor('feedbackText', {
header: t('CSAT_REPORTS.TABLE.HEADER.FEEDBACK_TEXT'), header: t('CSAT_REPORTS.TABLE.HEADER.FEEDBACK_TEXT'),
width: 400, width: 400,
cell: defaulSpanRender, cell: defaultSpanRender,
}), }),
columnHelper.accessor('conversationId', { columnHelper.accessor('conversationId', {
header: '', header: '',
@@ -172,6 +172,17 @@ export const actions = {
commit(types.SET_AGENT_BOT_UI_FLAG, { isDisconnecting: false }); commit(types.SET_AGENT_BOT_UI_FLAG, { isDisconnecting: false });
} }
}, },
resetAccessToken: async ({ commit }, botId) => {
try {
const response = await AgentBotsAPI.resetAccessToken(botId);
commit(types.EDIT_AGENT_BOT, response.data);
return response.data;
} catch (error) {
throwErrorMessage(error);
return null;
}
},
}; };
export const mutations = { export const mutations = {
@@ -213,6 +213,16 @@ export const actions = {
} }
}, },
resetAccessToken: async ({ commit }) => {
try {
const response = await authAPI.resetAccessToken();
commit(types.SET_CURRENT_USER, response.data);
return true;
} catch (error) {
return false;
}
},
resendConfirmation: async () => { resendConfirmation: async () => {
try { try {
await authAPI.resendConfirmation(); await authAPI.resendConfirmation();
@@ -18,13 +18,34 @@ const getters = {
getAllConversations: ({ allConversations, chatSortFilter: sortKey }) => { getAllConversations: ({ allConversations, chatSortFilter: sortKey }) => {
return allConversations.sort((a, b) => sortComparator(a, b, sortKey)); return allConversations.sort((a, b) => sortComparator(a, b, sortKey));
}, },
getFilteredConversations: ({ getFilteredConversations: (
allConversations, { allConversations, chatSortFilter, appliedFilters },
chatSortFilter, _,
appliedFilters, __,
}) => { rootGetters
) => {
const currentUser = rootGetters.getCurrentUser;
const currentUserId = rootGetters.getCurrentUser.id;
const currentAccountId = rootGetters.getCurrentAccountId;
const permissions = getUserPermissions(currentUser, currentAccountId);
const userRole = getUserRole(currentUser, currentAccountId);
return allConversations return allConversations
.filter(conversation => matchesFilters(conversation, appliedFilters)) .filter(conversation => {
const matchesFilterResult = matchesFilters(
conversation,
appliedFilters
);
const allowedForRole = applyRoleFilter(
conversation,
userRole,
permissions,
currentUserId
);
return matchesFilterResult && allowedForRole;
})
.sort((a, b) => sortComparator(a, b, chatSortFilter)); .sort((a, b) => sortComparator(a, b, chatSortFilter));
}, },
getSelectedChat: ({ selectedChatId, allConversations }) => { getSelectedChat: ({ selectedChatId, allConversations }) => {
@@ -170,4 +170,21 @@ describe('#actions', () => {
]); ]);
}); });
}); });
describe('#resetAccessToken', () => {
it('sends correct actions if API is success', async () => {
const mockResponse = {
data: { ...agentBotRecords[0], access_token: 'new_token_123' },
};
axios.post.mockResolvedValue(mockResponse);
const result = await actions.resetAccessToken(
{ commit },
agentBotRecords[0].id
);
expect(commit.mock.calls).toEqual([
[types.EDIT_AGENT_BOT, mockResponse.data],
]);
expect(result).toBe(mockResponse.data);
});
});
}); });
@@ -228,4 +228,20 @@ describe('#actions', () => {
); );
}); });
}); });
describe('#resetAccessToken', () => {
it('sends correct actions if API is success', async () => {
const mockResponse = {
data: { id: 1, name: 'John', access_token: 'new_token_123' },
headers: { expiry: 581842904 },
};
axios.post.mockResolvedValue(mockResponse);
const result = await actions.resetAccessToken({ commit });
expect(commit.mock.calls).toEqual([
[types.SET_CURRENT_USER, mockResponse.data],
]);
expect(result).toBe(true);
});
});
}); });
@@ -325,4 +325,308 @@ describe('#getters', () => {
}); });
}); });
}); });
describe('#getFilteredConversations', () => {
const mockConversations = [
{
id: 1,
status: 'open',
meta: { assignee: { id: 1 } },
last_activity_at: 1000,
},
{
id: 2,
status: 'open',
meta: {},
last_activity_at: 2000,
},
{
id: 3,
status: 'resolved',
meta: { assignee: { id: 2 } },
last_activity_at: 3000,
},
];
const mockRootGetters = {
getCurrentUser: {
id: 1,
accounts: [{ id: 1, role: 'agent', permissions: [] }],
},
getCurrentAccountId: 1,
};
it('filters conversations based on role permissions for administrator', () => {
const state = {
allConversations: mockConversations,
chatSortFilter: 'last_activity_at_desc',
appliedFilters: [],
};
const rootGetters = {
...mockRootGetters,
getCurrentUser: {
...mockRootGetters.getCurrentUser,
accounts: [{ id: 1, role: 'administrator', permissions: [] }],
},
};
const result = getters.getFilteredConversations(
state,
{},
{},
rootGetters
);
expect(result).toEqual([
mockConversations[2],
mockConversations[1],
mockConversations[0],
]);
});
it('filters conversations based on role permissions for agent', () => {
const state = {
allConversations: mockConversations,
chatSortFilter: 'last_activity_at_desc',
appliedFilters: [],
};
const rootGetters = {
...mockRootGetters,
getCurrentUser: {
...mockRootGetters.getCurrentUser,
accounts: [{ id: 1, role: 'agent', permissions: [] }],
},
};
const result = getters.getFilteredConversations(
state,
{},
{},
rootGetters
);
expect(result).toEqual([
mockConversations[2],
mockConversations[1],
mockConversations[0],
]);
});
it('filters conversations for custom role with conversation_manage permission', () => {
const state = {
allConversations: mockConversations,
chatSortFilter: 'last_activity_at_desc',
appliedFilters: [],
};
const rootGetters = {
...mockRootGetters,
getCurrentUser: {
...mockRootGetters.getCurrentUser,
accounts: [
{
id: 1,
custom_role_id: 5,
permissions: ['conversation_manage'],
},
],
},
};
const result = getters.getFilteredConversations(
state,
{},
{},
rootGetters
);
expect(result).toEqual([
mockConversations[2],
mockConversations[1],
mockConversations[0],
]);
});
it('filters conversations for custom role with conversation_unassigned_manage permission', () => {
const state = {
allConversations: mockConversations,
chatSortFilter: 'last_activity_at_desc',
appliedFilters: [],
};
const rootGetters = {
...mockRootGetters,
getCurrentUser: {
...mockRootGetters.getCurrentUser,
accounts: [
{
id: 1,
custom_role_id: 5,
permissions: ['conversation_unassigned_manage'],
},
],
},
};
const result = getters.getFilteredConversations(
state,
{},
{},
rootGetters
);
// Should include conversation assigned to user (id: 1) and unassigned conversation
expect(result).toEqual([mockConversations[1], mockConversations[0]]);
});
it('filters conversations for custom role with conversation_participating_manage permission', () => {
const state = {
allConversations: mockConversations,
chatSortFilter: 'last_activity_at_desc',
appliedFilters: [],
};
const rootGetters = {
...mockRootGetters,
getCurrentUser: {
...mockRootGetters.getCurrentUser,
accounts: [
{
id: 1,
custom_role_id: 5,
permissions: ['conversation_participating_manage'],
},
],
},
};
const result = getters.getFilteredConversations(
state,
{},
{},
rootGetters
);
// Should only include conversation assigned to user (id: 1)
expect(result).toEqual([mockConversations[0]]);
});
it('filters conversations for custom role with no permissions', () => {
const state = {
allConversations: mockConversations,
chatSortFilter: 'last_activity_at_desc',
appliedFilters: [],
};
const rootGetters = {
...mockRootGetters,
getCurrentUser: {
...mockRootGetters.getCurrentUser,
accounts: [
{
id: 1,
custom_role_id: 5,
permissions: [],
},
],
},
};
const result = getters.getFilteredConversations(
state,
{},
{},
rootGetters
);
// Should return empty array as user has no permissions
expect(result).toEqual([]);
});
it('applies filters and role permissions together', () => {
const state = {
allConversations: mockConversations,
chatSortFilter: 'last_activity_at_desc',
appliedFilters: [
{
attribute_key: 'status',
filter_operator: 'equal_to',
values: ['open'],
query_operator: 'and',
},
],
};
const rootGetters = {
...mockRootGetters,
getCurrentUser: {
...mockRootGetters.getCurrentUser,
accounts: [
{
id: 1,
custom_role_id: 5,
permissions: ['conversation_participating_manage'],
},
],
},
};
const result = getters.getFilteredConversations(
state,
{},
{},
rootGetters
);
// Should only include open conversation assigned to user (id: 1)
expect(result).toEqual([mockConversations[0]]);
});
it('returns empty array when no conversations match filters', () => {
const state = {
allConversations: mockConversations,
chatSortFilter: 'last_activity_at_desc',
appliedFilters: [
{
attribute_key: 'status',
filter_operator: 'equal_to',
values: ['pending'],
query_operator: 'and',
},
],
};
const result = getters.getFilteredConversations(
state,
{},
{},
mockRootGetters
);
expect(result).toEqual([]);
});
it('sorts filtered conversations according to chatSortFilter', () => {
const state = {
allConversations: mockConversations,
chatSortFilter: 'last_activity_at_asc',
appliedFilters: [],
};
const result = getters.getFilteredConversations(
state,
{},
{},
mockRootGetters
);
expect(result).toEqual([
mockConversations[0],
mockConversations[1],
mockConversations[2],
]);
});
});
}); });
+3 -3
View File
@@ -64,11 +64,11 @@ export default {
:selected="modelValue" :selected="modelValue"
:name="name" :name="name"
:class="{ :class="{
'text-ash-400': !modelValue, 'text-n-slate-9': !modelValue,
'text-ash-900': modelValue, 'text-n-slate-12': modelValue,
'pl-9': icon, 'pl-9': icon,
}" }"
class="block w-full px-3 py-2 pr-6 mb-0 border-0 shadow-sm outline-none appearance-none rounded-xl select-caret ring-ash-200 ring-1 ring-inset placeholder:text-ash-900 focus:ring-2 focus:ring-inset focus:ring-primary-500 text-sm leading-6" class="block w-full px-3 py-2 pr-6 mb-0 border-0 shadow-sm appearance-none rounded-xl select-caret leading-6"
@input="onInput" @input="onInput"
> >
<option value="" disabled selected class="hidden"> <option value="" disabled selected class="hidden">
@@ -5,7 +5,6 @@ module ActivityMessageHandler
include LabelActivityMessageHandler include LabelActivityMessageHandler
include SlaActivityMessageHandler include SlaActivityMessageHandler
include TeamActivityMessageHandler include TeamActivityMessageHandler
include CsatActivityMessageHandler
private private
@@ -1,8 +0,0 @@
module CsatActivityMessageHandler
extend ActiveSupport::Concern
def create_csat_not_sent_activity_message
content = I18n.t('conversations.activity.csat.not_sent_due_to_messaging_window')
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
end
end
+4
View File
@@ -22,4 +22,8 @@ class AgentBotPolicy < ApplicationPolicy
def avatar? def avatar?
@account_user.administrator? @account_user.administrator?
end end
def reset_access_token?
@account_user.administrator?
end
end end
@@ -11,3 +11,5 @@ class CsatSurveyResponsePolicy < ApplicationPolicy
@account_user.administrator? @account_user.administrator?
end end
end end
CsatSurveyResponsePolicy.prepend_mod_with('CsatSurveyResponsePolicy')
@@ -17,7 +17,7 @@ class MessageTemplates::HookExecutionService
::MessageTemplates::Template::OutOfOffice.new(conversation: conversation).perform if should_send_out_of_office_message? ::MessageTemplates::Template::OutOfOffice.new(conversation: conversation).perform if should_send_out_of_office_message?
::MessageTemplates::Template::Greeting.new(conversation: conversation).perform if should_send_greeting? ::MessageTemplates::Template::Greeting.new(conversation: conversation).perform if should_send_greeting?
::MessageTemplates::Template::EmailCollect.new(conversation: conversation).perform if inbox.enable_email_collect && should_send_email_collect? ::MessageTemplates::Template::EmailCollect.new(conversation: conversation).perform if inbox.enable_email_collect && should_send_email_collect?
handle_csat_survey ::MessageTemplates::Template::CsatSurvey.new(conversation: conversation).perform if should_send_csat_survey?
end end
def should_send_out_of_office_message? def should_send_out_of_office_message?
@@ -65,26 +65,13 @@ class MessageTemplates::HookExecutionService
true true
end end
def handle_csat_survey def should_send_csat_survey?
return unless csat_enabled_conversation? return unless csat_enabled_conversation?
# only send CSAT once in a conversation # only send CSAT once in a conversation
return if csat_already_sent? return if conversation.messages.where(content_type: :input_csat).present?
# Only send CSAT if agent can still reply by checking the messaging window restriction true
# https://www.chatwoot.com/docs/self-hosted/supported-features#outgoing-message-restriction
if within_messaging_window?
::MessageTemplates::Template::CsatSurvey.new(conversation: conversation).perform
else
conversation.create_csat_not_sent_activity_message
end
end
def csat_already_sent?
conversation.messages.where(content_type: :input_csat).present?
end
def within_messaging_window?
conversation.can_reply?
end end
end end
MessageTemplates::HookExecutionService.prepend_mod_with('MessageTemplates::HookExecutionService') MessageTemplates::HookExecutionService.prepend_mod_with('MessageTemplates::HookExecutionService')
@@ -0,0 +1 @@
json.partial! 'api/v1/models/agent_bot', formats: [:json], resource: AgentBotPresenter.new(@agent_bot)
@@ -0,0 +1 @@
json.partial! 'api/v1/models/user', formats: [:json], resource: @user
+24
View File
@@ -73,6 +73,30 @@ By default, it renders:
<% end %> <% end %>
</main> </main>
</div> </div>
<% if @article.present? %>
<script>
(function() {
let viewTracked = false;
const trackView = function() {
if (!viewTracked) {
viewTracked = true;
const img = new Image();
img.src = '<%= request.base_url %>/hc/<%= @portal.slug %>/articles/<%= @article.slug %>.png';
}
};
const addTrackingListeners = function() {
const events = ['mouseenter', 'touchstart', 'focus'];
events.forEach(event => {
document.body.addEventListener(event, function() {
setTimeout(trackView, 5000);
}, { once: true });
});
};
addTrackingListeners();
})();
</script>
<% end %>
</body> </body>
<style> <style>
html.dark { html.dark {
+1 -1
View File
@@ -66,7 +66,7 @@ Rails.application.configure do
# GitHub Codespaces configuration # GitHub Codespaces configuration
if ENV['CODESPACES'] if ENV['CODESPACES']
# Allow web console access from any IP # Allow web console access from any IP
config.web_console.whitelisted_ips = %w(0.0.0.0/0 ::/0) config.web_console.allowed_ips = %w(0.0.0.0/0 ::/0)
# Allow CSRF from codespace URLs # Allow CSRF from codespace URLs
config.force_ssl = false config.force_ssl = false
config.action_controller.forgery_protection_origin_check = false config.action_controller.forgery_protection_origin_check = false
+3 -2
View File
@@ -48,6 +48,9 @@ en:
email_already_exists: 'You have already signed up for an account with %{email}' email_already_exists: 'You have already signed up for an account with %{email}'
invalid_params: 'Invalid, please check the signup paramters and try again' invalid_params: 'Invalid, please check the signup paramters and try again'
failed: Signup failed failed: Signup failed
oauth:
insufficient_scopes: 'Insufficient permissions granted. Missing scopes: %{scopes}'
authorization_failed: 'Authorization failed'
data_import: data_import:
data_type: data_type:
invalid: Invalid data type invalid: Invalid data type
@@ -185,8 +188,6 @@ en:
removed: '%{user_name} removed %{labels}' removed: '%{user_name} removed %{labels}'
sla: sla:
added: '%{user_name} added SLA policy %{sla_name}' added: '%{user_name} added SLA policy %{sla_name}'
csat:
not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
removed: '%{user_name} removed SLA policy %{sla_name}' removed: '%{user_name} removed SLA policy %{sla_name}'
muted: '%{user_name} has muted the conversation' muted: '%{user_name} has muted the conversation'
unmuted: '%{user_name} has unmuted the conversation' unmuted: '%{user_name} has unmuted the conversation'
+132
View File
@@ -0,0 +1,132 @@
# Markdown Embed Configuration
#
# This file defines patterns and templates for converting URLs into embedded content
# in markdown rendering. Each embed type has:
# - regex: Pattern with named capture groups (?<name>...)
# - template: HTML template with %{capture_group_name} placeholders
#
# To add a new embed type:
# 1. Add a new top-level key
# 2. Define the regex pattern with named capture groups: (?<name>pattern)
# 3. Create an HTML template using %{name} placeholders matching the capture groups
youtube:
regex: 'https?://(?:www\.)?(?:youtube\.com/watch\?v=|youtu\.be/)(?<video_id>[^&/]+)'
template: |
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
<iframe
src="https://www.youtube-nocookie.com/embed/%{video_id}"
frameborder="0"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen></iframe>
</div>
loom:
regex: 'https?://(?:www\.)?loom\.com/share/(?<video_id>[^&/]+)'
template: |
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
<iframe
src="https://www.loom.com/embed/%{video_id}"
frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></iframe>
</div>
vimeo:
regex: 'https?://(?:www\.)?vimeo\.com/(?<video_id>\d+)'
template: |
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
<iframe
src="https://player.vimeo.com/video/%{video_id}?dnt=true"
frameborder="0"
allow="autoplay; fullscreen; picture-in-picture"
allowfullscreen
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></iframe>
</div>
mp4:
regex: '(?<link_url>https?://(?:www\.)?.+\.mp4)'
template: |
<video width="640" height="360" controls>
<source src="%{link_url}" type="video/mp4">
Your browser does not support the video tag.
</video>
arcade:
regex: 'https?://(?:www\.)?app\.arcade\.software/share/(?<video_id>[^&/]+)'
template: |
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
<iframe
src="https://app.arcade.software/embed/%{video_id}"
frameborder="0"
webkitallowfullscreen
mozallowfullscreen
allowfullscreen
allow="fullscreen"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;">
</iframe>
</div>
wistia:
regex: 'https?://(?:www\.)?[^/]+\.wistia\.com/medias/(?<video_id>[^&/]+)'
template: |
<div style="position: relative; padding-bottom: 56.25%; height: 0;">
<script src="https://fast.wistia.com/player.js" async></script>
<script src="https://fast.wistia.com/embed/%{video_id}.js" async type="module"></script>
<style>
wistia-player[media-id='%{video_id}']:not(:defined) {
background: center / contain no-repeat url('https://fast.wistia.com/embed/medias/%{video_id}/swatch');
display: block;
filter: blur(5px);
padding-top:56.25%;
}
</style>
<wistia-player
media-id="%{video_id}"
aspect="1.7777777777777777"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;">
</wistia-player>
</div>
bunny:
regex: 'https?://iframe\.mediadelivery\.net/play/(?<library_id>\d+)/(?<video_id>[^&/?]+)'
template: |
<div style="position: relative; padding-top: 56.25%;">
<iframe
src="https://iframe.mediadelivery.net/embed/%{library_id}/%{video_id}?autoplay=false&loop=false&muted=false&preload=true&responsive=true"
title="Bunny video player"
loading="lazy"
style="border: 0; position: absolute; top: 0; height: 100%; width: 100%;"
allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture;"
allowfullscreen>
</iframe>
</div>
codepen:
regex: 'https?://(?:www\.)?codepen\.io/(?<user>[^/]+)/pen/(?<pen_id>[^/?]+)'
template: |
<div style="height: 400px; box-sizing: border-box; display: flex; align-items: center; justify-content: center;">
<iframe
height="400"
style="width: 100%;"
scrolling="no"
title="CodePen Embed"
src="https://codepen.io/%{user}/embed/%{pen_id}?default-tab=result"
frameborder="no"
loading="lazy"
allowtransparency="true"
allowfullscreen="true">
</iframe>
</div>
github_gist:
regex: 'https?://gist\.github\.com/(?<username>[^/]+)/(?<gist_id>[a-f0-9]+)'
template: |
<script src="https://gist.github.com/%{username}/%{gist_id}.js"></script>
<noscript>
<div style="border: 1px solid #d1d9e0; border-radius: 6px; padding: 16px; margin: 16px 0; background: #f6f8fa;">
<a href="https://gist.github.com/%{username}/%{gist_id}" target="_blank" style="color: #0969da; text-decoration: none;">
View this gist on GitHub
</a>
</div>
</noscript>
+3
View File
@@ -67,6 +67,7 @@ Rails.application.routes.draw do
end end
resources :agent_bots, only: [:index, :create, :show, :update, :destroy] do resources :agent_bots, only: [:index, :create, :show, :update, :destroy] do
delete :avatar, on: :member delete :avatar, on: :member
post :reset_access_token, on: :member
end end
resources :contact_inboxes, only: [] do resources :contact_inboxes, only: [] do
collection do collection do
@@ -296,6 +297,7 @@ Rails.application.routes.draw do
post :auto_offline post :auto_offline
put :set_active_account put :set_active_account
post :resend_confirmation post :resend_confirmation
post :reset_access_token
end end
end end
@@ -447,6 +449,7 @@ Rails.application.routes.draw do
get 'hc/:slug/:locale/categories', to: 'public/api/v1/portals/categories#index' get 'hc/:slug/:locale/categories', to: 'public/api/v1/portals/categories#index'
get 'hc/:slug/:locale/categories/:category_slug', to: 'public/api/v1/portals/categories#show' get 'hc/:slug/:locale/categories/:category_slug', to: 'public/api/v1/portals/categories#show'
get 'hc/:slug/:locale/categories/:category_slug/articles', to: 'public/api/v1/portals/articles#index' get 'hc/:slug/:locale/categories/:category_slug/articles', to: 'public/api/v1/portals/articles#index'
get 'hc/:slug/articles/:article_slug.png', to: 'public/api/v1/portals/articles#tracking_pixel'
get 'hc/:slug/articles/:article_slug', to: 'public/api/v1/portals/articles#show' get 'hc/:slug/articles/:article_slug', to: 'public/api/v1/portals/articles#show'
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
@@ -0,0 +1,13 @@
module Enterprise::CsatSurveyResponsePolicy
def index?
@account_user.custom_role&.permissions&.include?('report_manage') || super
end
def metrics?
@account_user.custom_role&.permissions&.include?('report_manage') || super
end
def download?
@account_user.custom_role&.permissions&.include?('report_manage') || super
end
end
+35 -60
View File
@@ -1,13 +1,13 @@
class CustomMarkdownRenderer < CommonMarker::HtmlRenderer class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
# TODO: let move this regex from here to a config file where we can update this list much more easily CONFIG_PATH = Rails.root.join('config/markdown_embeds.yml')
# the config file will also have the matching embed template as well.
YOUTUBE_REGEX = %r{https?://(?:www\.)?(?:youtube\.com/watch\?v=|youtu\.be/)([^&/]+)} def self.config
LOOM_REGEX = %r{https?://(?:www\.)?loom\.com/share/([^&/]+)} @config ||= YAML.load_file(CONFIG_PATH)
VIMEO_REGEX = %r{https?://(?:www\.)?vimeo\.com/(\d+)} end
MP4_REGEX = %r{https?://(?:www\.)?.+\.(mp4)}
ARCADE_REGEX = %r{https?://(?:www\.)?app\.arcade\.software/share/([^&/]+)} def self.embed_regexes
WISTIA_REGEX = %r{https?://(?:www\.)?([^/]+)\.wistia\.com/medias/([^&/]+)} @embed_regexes ||= config.transform_values { |embed_config| Regexp.new(embed_config['regex']) }
BUNNY_REGEX = %r{https?://iframe\.mediadelivery\.net/play/(\d+)/([^&/?]+)} end
def text(node) def text(node)
content = node.string_content content = node.string_content
@@ -23,7 +23,7 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
def link(node) def link(node)
return if surrounded_by_empty_lines?(node) && render_embedded_content(node) return if surrounded_by_empty_lines?(node) && render_embedded_content(node)
# If it's not YouTube or Vimeo link, render normally # If it's not a supported embed link, render normally
super super
end end
@@ -47,25 +47,35 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
def render_embedded_content(node) def render_embedded_content(node)
link_url = node.url link_url = node.url
embedding_methods = { embed_html = find_matching_embed(link_url)
YOUTUBE_REGEX => :make_youtube_embed,
VIMEO_REGEX => :make_vimeo_embed,
MP4_REGEX => :make_video_embed,
LOOM_REGEX => :make_loom_embed,
ARCADE_REGEX => :make_arcade_embed,
WISTIA_REGEX => :make_wistia_embed,
BUNNY_REGEX => :make_bunny_embed
}
embedding_methods.each do |regex, method| return false unless embed_html
out(embed_html)
true
end
def find_matching_embed(link_url)
self.class.embed_regexes.each do |embed_key, regex|
match = link_url.match(regex) match = link_url.match(regex)
if match next unless match
out(send(method, match))
return true return render_embed_from_match(embed_key, match)
end
end end
false nil
end
def render_embed_from_match(embed_key, match_data)
embed_config = self.class.config[embed_key]
return nil unless embed_config
template = embed_config['template']
# Use Ruby's built-in named captures with gsub to handle CSS % values
match_data.named_captures.each do |var_name, value|
template = template.gsub("%{#{var_name}}", value)
end
template
end end
def parse_sup(content) def parse_sup(content)
@@ -77,39 +87,4 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
end end
end end
end end
def make_youtube_embed(youtube_match)
video_id = youtube_match[1]
EmbedRenderer.youtube(video_id)
end
def make_loom_embed(loom_match)
video_id = loom_match[1]
EmbedRenderer.loom(video_id)
end
def make_vimeo_embed(vimeo_match)
video_id = vimeo_match[1]
EmbedRenderer.vimeo(video_id)
end
def make_video_embed(link_url)
EmbedRenderer.video(link_url)
end
def make_wistia_embed(wistia_match)
video_id = wistia_match[2]
EmbedRenderer.wistia(video_id)
end
def make_arcade_embed(arcade_match)
video_id = arcade_match[1]
EmbedRenderer.arcade(video_id)
end
def make_bunny_embed(bunny_match)
library_id = bunny_match[1]
video_id = bunny_match[2]
EmbedRenderer.bunny(library_id, video_id)
end
end end
-102
View File
@@ -1,102 +0,0 @@
module EmbedRenderer
def self.youtube(video_id)
%(
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
<iframe
src="https://www.youtube-nocookie.com/embed/#{video_id}"
frameborder="0"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen></iframe>
</div>
)
end
def self.loom(video_id)
%(
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
<iframe
src="https://www.loom.com/embed/#{video_id}"
frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></iframe>
</div>
)
end
def self.vimeo(video_id)
%(
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
<iframe
src="https://player.vimeo.com/video/#{video_id}?dnt=true"
frameborder="0"
allow="autoplay; fullscreen; picture-in-picture"
allowfullscreen
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></iframe>
</div>
)
end
def self.video(link_url)
%(
<video width="640" height="360" controls>
<source src="#{link_url}" type="video/mp4">
Your browser does not support the video tag.
</video>
)
end
# Generates an HTML embed for a Wistia video.
# @param wistia_match [MatchData] A match object from the WISTIA_REGEX regex, where wistia_match[2] contains the video ID.
def self.wistia(video_id)
%(
<div style="position: relative; padding-bottom: 56.25%; height: 0;">
<script src="https://fast.wistia.com/player.js" async></script>
<script src="https://fast.wistia.com/embed/#{video_id}.js" async type="module"></script>
<style>
wistia-player[media-id='#{video_id}']:not(:defined) {
background: center / contain no-repeat url('https://fast.wistia.com/embed/medias/#{video_id}/swatch');
display: block;
filter: blur(5px);
padding-top:56.25%;
}
</style>
<wistia-player
media-id="#{video_id}"
aspect="1.7777777777777777"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;">
</wistia-player>
</div>
)
end
def self.arcade(video_id)
%(
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
<iframe
src="https://app.arcade.software/embed/#{video_id}"
frameborder="0"
webkitallowfullscreen
mozallowfullscreen
allowfullscreen
allow="fullscreen"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;">
</iframe>
</div>
)
end
def self.bunny(library_id, video_id)
%(
<div style="position: relative; padding-top: 56.25%;">
<iframe
src="https://iframe.mediadelivery.net/embed/#{library_id}/#{video_id}?autoplay=false&loop=false&muted=false&preload=true&responsive=true"
title="Bunny video player"
loading="lazy"
style="border: 0; position: absolute; top: 0; height: 100%; width: 100%;"
allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture;"
allowfullscreen>
</iframe>
</div>
)
end
end
+45 -8
View File
@@ -19,6 +19,7 @@ class Seeders::AccountSeeder
def perform! def perform!
set_up_account set_up_account
seed_teams seed_teams
seed_custom_roles
set_up_users set_up_users
seed_labels seed_labels
seed_canned_responses seed_canned_responses
@@ -32,6 +33,7 @@ class Seeders::AccountSeeder
@account.labels.destroy_all @account.labels.destroy_all
@account.inboxes.destroy_all @account.inboxes.destroy_all
@account.contacts.destroy_all @account.contacts.destroy_all
@account.custom_roles.destroy_all if @account.respond_to?(:custom_roles)
end end
def seed_teams def seed_teams
@@ -40,6 +42,18 @@ class Seeders::AccountSeeder
end end
end end
def seed_custom_roles
return unless @account_data['custom_roles'].present? && @account.respond_to?(:custom_roles)
@account_data['custom_roles'].each do |role_data|
@account.custom_roles.create!(
name: role_data['name'],
description: role_data['description'],
permissions: role_data['permissions']
)
end
end
def seed_labels def seed_labels
@account_data['labels'].each do |label| @account_data['labels'].each do |label|
@account.labels.create!(label) @account.labels.create!(label)
@@ -48,17 +62,34 @@ class Seeders::AccountSeeder
def set_up_users def set_up_users
@account_data['users'].each do |user| @account_data['users'].each do |user|
user_record = User.create_with(name: user['name'], password: 'Password1!.').find_or_create_by!(email: (user['email']).to_s) user_record = create_user_record(user)
user_record.skip_confirmation! create_account_user(user_record, user)
user_record.save! add_user_to_teams(user: user_record, teams: user['team']) if user['team'].present?
Avatar::AvatarFromUrlJob.perform_later(user_record, "https://xsgames.co/randomusers/avatar.php?g=#{user['gender']}")
AccountUser.create_with(role: (user['role'] || 'agent')).find_or_create_by!(account_id: @account.id, user_id: user_record.id)
next if user['team'].blank?
add_user_to_teams(user: user_record, teams: user['team'])
end end
end end
private
def create_user_record(user)
user_record = User.create_with(name: user['name'], password: 'Password1!.').find_or_create_by!(email: user['email'].to_s)
user_record.skip_confirmation!
user_record.save!
Avatar::AvatarFromUrlJob.perform_later(user_record, "https://xsgames.co/randomusers/avatar.php?g=#{user['gender']}")
user_record
end
def create_account_user(user_record, user)
account_user_attrs = build_account_user_attrs(user)
AccountUser.create_with(account_user_attrs).find_or_create_by!(account_id: @account.id, user_id: user_record.id)
end
def build_account_user_attrs(user)
attrs = { role: (user['role'] || 'agent') }
custom_role = find_custom_role(user['custom_role']) if user['custom_role'].present?
attrs[:custom_role] = custom_role if custom_role
attrs
end
def add_user_to_teams(user:, teams:) def add_user_to_teams(user:, teams:)
teams.each do |team| teams.each do |team|
team_record = @account.teams.where('name LIKE ?', "%#{team.downcase}%").first if team.present? team_record = @account.teams.where('name LIKE ?', "%#{team.downcase}%").first if team.present?
@@ -66,6 +97,12 @@ class Seeders::AccountSeeder
end end
end end
def find_custom_role(role_name)
return nil unless @account.respond_to?(:custom_roles)
@account.custom_roles.find_by(name: role_name)
end
def seed_canned_responses(count: 50) def seed_canned_responses(count: 50)
count.times do count.times do
@account.canned_responses.create(content: Faker::Quote.fortune_cookie, short_code: Faker::Alphanumeric.alpha(number: 10)) @account.canned_responses.create(content: Faker::Quote.fortune_cookie, short_code: Faker::Alphanumeric.alpha(number: 10))
+43
View File
@@ -61,41 +61,49 @@ users:
email: 'karn@paperlayer.test' email: 'karn@paperlayer.test'
team: team:
- 'Sales' - 'Sales'
custom_role: 'Sales Representative'
- name: 'Danny Cordray' - name: 'Danny Cordray'
gender: female gender: female
email: 'danny@paperlayer.test' email: 'danny@paperlayer.test'
team: team:
- 'Sales' - 'Sales'
custom_role: 'Customer Support Lead'
- name: 'Ben Nugent' - name: 'Ben Nugent'
gender: male gender: male
email: 'ben@paperlayer.test' email: 'ben@paperlayer.test'
team: team:
- 'Sales' - 'Sales'
custom_role: 'Junior Agent'
- name: 'Todd Packer' - name: 'Todd Packer'
gender: male gender: male
email: 'todd@paperlayer.test' email: 'todd@paperlayer.test'
team: team:
- 'Sales' - 'Sales'
custom_role: 'Sales Representative'
- name: 'Cathy Simms' - name: 'Cathy Simms'
gender: female gender: female
email: 'cathy@paperlayer.test' email: 'cathy@paperlayer.test'
team: team:
- 'Administration' - 'Administration'
custom_role: 'Knowledge Manager'
- name: 'Hunter Jo' - name: 'Hunter Jo'
gender: male gender: male
email: 'hunter@paperlayer.test' email: 'hunter@paperlayer.test'
team: team:
- 'Administration' - 'Administration'
custom_role: 'Analytics Specialist'
- name: 'Rolando Silva' - name: 'Rolando Silva'
gender: male gender: male
email: 'rolando@paperlayer.test' email: 'rolando@paperlayer.test'
team: team:
- 'Administration' - 'Administration'
custom_role: 'Junior Agent'
- name: 'Stephanie Wilson' - name: 'Stephanie Wilson'
gender: female gender: female
email: 'stephanie@paperlayer.test' email: 'stephanie@paperlayer.test'
team: team:
- 'Administration' - 'Administration'
custom_role: 'Escalation Handler'
- name: 'Jordan Garfield' - name: 'Jordan Garfield'
gender: male gender: male
email: 'jorodan@paperlayer.test' email: 'jorodan@paperlayer.test'
@@ -111,6 +119,7 @@ users:
email: 'lonny@paperlayer.test' email: 'lonny@paperlayer.test'
team: team:
- 'Warehouse' - 'Warehouse'
custom_role: 'Customer Support Lead'
- name: 'Madge Madsen' - name: 'Madge Madsen'
gender: female gender: female
email: 'madge@paperlayer.test' email: 'madge@paperlayer.test'
@@ -162,6 +171,7 @@ users:
- name: 'Devon White' - name: 'Devon White'
gender: male gender: male
email: 'devon@paperlayer.test' email: 'devon@paperlayer.test'
custom_role: 'Escalation Handler'
- name: 'Kendall' - name: 'Kendall'
gender: male gender: male
email: 'kendall@paperlayer.test' email: 'kendall@paperlayer.test'
@@ -173,6 +183,39 @@ teams:
- '💼 Management' - '💼 Management'
- '👩‍💼 Administration' - '👩‍💼 Administration'
- '🚛 Warehouse' - '🚛 Warehouse'
custom_roles:
- name: 'Customer Support Lead'
description: 'Lead support agent with full conversation and contact management'
permissions:
- 'conversation_manage'
- 'contact_manage'
- 'report_manage'
- name: 'Sales Representative'
description: 'Sales team member with conversation and contact access'
permissions:
- 'conversation_unassigned_manage'
- 'conversation_participating_manage'
- 'contact_manage'
- name: 'Knowledge Manager'
description: 'Manages knowledge base and participates in conversations'
permissions:
- 'knowledge_base_manage'
- 'conversation_participating_manage'
- name: 'Junior Agent'
description: 'Entry-level agent with basic conversation access'
permissions:
- 'conversation_participating_manage'
- name: 'Analytics Specialist'
description: 'Focused on reports and data analysis'
permissions:
- 'report_manage'
- 'conversation_participating_manage'
- name: 'Escalation Handler'
description: 'Handles unassigned conversations and escalations'
permissions:
- 'conversation_unassigned_manage'
- 'conversation_participating_manage'
- 'contact_manage'
labels: labels:
- title: 'billing' - title: 'billing'
color: '#28AD21' color: '#28AD21'
Binary file not shown.

After

Width:  |  Height:  |  Size: 123 B

+103
View File
@@ -0,0 +1,103 @@
require 'rails_helper'
# rubocop:disable RSpec/DescribeClass
describe 'Markdown Embeds Configuration' do
# rubocop:enable RSpec/DescribeClass
let(:config) { YAML.load_file(Rails.root.join('config/markdown_embeds.yml')) }
describe 'YAML structure' do
it 'loads valid YAML' do
expect(config).to be_a(Hash)
expect(config).not_to be_empty
end
it 'has required keys for each embed type' do
config.each do |embed_type, embed_config|
expect(embed_config).to have_key('regex'), "#{embed_type} missing regex"
expect(embed_config).to have_key('template'), "#{embed_type} missing template"
expect(embed_config['regex']).to be_a(String), "#{embed_type} regex should be string"
expect(embed_config['template']).to be_a(String), "#{embed_type} template should be string"
end
end
it 'contains expected embed types' do
expected_types = %w[youtube loom vimeo mp4 arcade wistia bunny codepen github_gist]
expect(config.keys).to match_array(expected_types)
end
end
describe 'regex patterns and named capture groups' do
let(:test_cases) do
{
'youtube' => [
{ url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', expected: { 'video_id' => 'dQw4w9WgXcQ' } },
{ url: 'https://youtu.be/dQw4w9WgXcQ', expected: { 'video_id' => 'dQw4w9WgXcQ' } },
{ url: 'https://youtube.com/watch?v=abc123XYZ', expected: { 'video_id' => 'abc123XYZ' } }
],
'loom' => [
{ url: 'https://www.loom.com/share/abc123def456', expected: { 'video_id' => 'abc123def456' } },
{ url: 'https://loom.com/share/xyz789', expected: { 'video_id' => 'xyz789' } }
],
'vimeo' => [
{ url: 'https://vimeo.com/123456789', expected: { 'video_id' => '123456789' } },
{ url: 'https://www.vimeo.com/987654321', expected: { 'video_id' => '987654321' } }
],
'mp4' => [
{ url: 'https://example.com/video.mp4', expected: { 'link_url' => 'https://example.com/video.mp4' } },
{ url: 'https://www.test.com/path/to/movie.mp4', expected: { 'link_url' => 'https://www.test.com/path/to/movie.mp4' } }
],
'arcade' => [
{ url: 'https://app.arcade.software/share/arcade123', expected: { 'video_id' => 'arcade123' } },
{ url: 'https://www.app.arcade.software/share/demo456', expected: { 'video_id' => 'demo456' } }
],
'wistia' => [
{ url: 'https://chatwoot.wistia.com/medias/kjwjeq6f9i', expected: { 'video_id' => 'kjwjeq6f9i' } },
{ url: 'https://www.company.wistia.com/medias/abc123def', expected: { 'video_id' => 'abc123def' } }
],
'bunny' => [
{ url: 'https://iframe.mediadelivery.net/play/431789/1f105841-cad9-46fe-a70e-b7623c60797c',
expected: { 'library_id' => '431789', 'video_id' => '1f105841-cad9-46fe-a70e-b7623c60797c' } },
{ url: 'https://iframe.mediadelivery.net/play/12345/abcdef-ghijkl', expected: { 'library_id' => '12345', 'video_id' => 'abcdef-ghijkl' } }
],
'codepen' => [
{ url: 'https://codepen.io/username/pen/abcdef', expected: { 'user' => 'username', 'pen_id' => 'abcdef' } },
{ url: 'https://www.codepen.io/testuser/pen/xyz123', expected: { 'user' => 'testuser', 'pen_id' => 'xyz123' } }
],
'github_gist' => [
{ url: 'https://gist.github.com/username/1234567890abcdef1234567890abcdef',
expected: { 'username' => 'username', 'gist_id' => '1234567890abcdef1234567890abcdef' } },
{ url: 'https://gist.github.com/testuser/fedcba0987654321fedcba0987654321', expected: { 'username' => 'testuser', 'gist_id' => 'fedcba0987654321fedcba0987654321' } }
]
}
end
it 'correctly captures named groups for all embed types' do
test_cases.each do |embed_type, cases|
regex = Regexp.new(config[embed_type]['regex'])
cases.each do |test_case|
match = regex.match(test_case[:url])
expect(match).not_to be_nil, "#{embed_type} regex failed to match URL: #{test_case[:url]}"
expect(match.named_captures).to eq(test_case[:expected]),
"#{embed_type} captured groups don't match expected for URL: #{test_case[:url]}"
end
end
end
it 'validates that template variables match capture group names' do
config.each do |embed_type, embed_config|
regex = Regexp.new(embed_config['regex'])
template = embed_config['template']
# Extract template variables like %{video_id}
template_vars = template.scan(/%\{(\w+)\}/).flatten.uniq
# Get named capture groups from regex
capture_names = regex.names
expect(capture_names).to match_array(template_vars),
"#{embed_type}: Template variables #{template_vars} don't match capture groups #{capture_names}"
end
end
end
end
@@ -262,4 +262,55 @@ RSpec.describe 'Agent Bot API', type: :request do
end end
end end
end end
describe 'POST /api/v1/accounts/{account.id}/agent_bots/:id/reset_access_token' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/agent_bots/#{agent_bot.id}/reset_access_token"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
it 'regenerates the access token when administrator' do
old_token = agent_bot.access_token.token
post "/api/v1/accounts/#{account.id}/agent_bots/#{agent_bot.id}/reset_access_token",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
agent_bot.reload
expect(agent_bot.access_token.token).not_to eq(old_token)
json_response = response.parsed_body
expect(json_response['access_token']).to eq(agent_bot.access_token.token)
end
it 'would not reset the access token when agent' do
old_token = agent_bot.access_token.token
post "/api/v1/accounts/#{account.id}/agent_bots/#{agent_bot.id}/reset_access_token",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
agent_bot.reload
expect(agent_bot.access_token.token).to eq(old_token)
end
it 'would not reset access token for a global agent bot' do
global_bot = create(:agent_bot)
old_token = global_bot.access_token.token
post "/api/v1/accounts/#{account.id}/agent_bots/#{global_bot.id}/reset_access_token",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:not_found)
global_bot.reload
expect(global_bot.access_token.token).to eq(old_token)
end
end
end
end end
@@ -296,4 +296,32 @@ RSpec.describe 'Profile API', type: :request do
end end
end end
end end
describe 'POST /api/v1/profile/reset_access_token' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
post '/api/v1/profile/reset_access_token'
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
let(:agent) { create(:user, account: account, role: :agent) }
it 'regenerates the access token' do
old_token = agent.access_token.token
post '/api/v1/profile/reset_access_token',
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
agent.reload
expect(agent.access_token.token).not_to eq(old_token)
json_response = response.parsed_body
expect(json_response['access_token']).to eq(agent.access_token.token)
end
end
end
end end
@@ -13,12 +13,17 @@ RSpec.describe 'Google::CallbacksController', type: :request do
describe 'GET /google/callback' do describe 'GET /google/callback' do
let(:response_body_success) do let(:response_body_success) do
{ id_token: JWT.encode({ email: email, name: 'test' }, false), access_token: SecureRandom.hex(10), token_type: 'Bearer', { id_token: JWT.encode({ email: email, name: 'test' }, false), access_token: SecureRandom.hex(10), token_type: 'Bearer',
refresh_token: SecureRandom.hex(10) } refresh_token: SecureRandom.hex(10), scope: 'https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email https://mail.google.com/' }
end end
let(:response_body_success_without_name) do let(:response_body_success_without_name) do
{ id_token: JWT.encode({ email: email }, false), access_token: SecureRandom.hex(10), token_type: 'Bearer', { id_token: JWT.encode({ email: email }, false), access_token: SecureRandom.hex(10), token_type: 'Bearer',
refresh_token: SecureRandom.hex(10) } refresh_token: SecureRandom.hex(10), scope: 'https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email https://mail.google.com/' }
end
let(:response_body_insufficient_scopes) do
{ id_token: JWT.encode({ email: email, name: 'test' }, false), access_token: SecureRandom.hex(10), token_type: 'Bearer',
refresh_token: SecureRandom.hex(10), scope: 'https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email' }
end end
it 'creates inboxes if authentication is successful' do it 'creates inboxes if authentication is successful' do
@@ -73,7 +78,20 @@ RSpec.describe 'Google::CallbacksController', type: :request do
expect(inbox.name).to eq email.split('@').first.parameterize.titleize expect(inbox.name).to eq email.split('@').first.parameterize.titleize
end end
it 'redirects to google app in case of error' do it 'redirects to custom error URL when insufficient scopes are granted' do
stub_request(:post, 'https://accounts.google.com/o/oauth2/token')
.with(body: { 'code' => code, 'grant_type' => 'authorization_code',
'redirect_uri' => "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/google/callback" })
.to_return(status: 200, body: response_body_insufficient_scopes.to_json, headers: { 'Content-Type' => 'application/json' })
get google_callback_url, params: { code: code }
expect(response).to redirect_to(/#{Regexp.escape("#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/app/accounts/#{account.id}/settings/inboxes/new/email")}/)
expect(response.location).to include('error=')
expect(Redis::Alfred.get(cache_key).to_i).to eq account.id
end
it 'redirects to custom error URL in case of OAuth error' do
stub_request(:post, 'https://accounts.google.com/o/oauth2/token') stub_request(:post, 'https://accounts.google.com/o/oauth2/token')
.with(body: { 'code' => code, 'grant_type' => 'authorization_code', .with(body: { 'code' => code, 'grant_type' => 'authorization_code',
'redirect_uri' => "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/google/callback" }) 'redirect_uri' => "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/google/callback" })
@@ -81,7 +99,8 @@ RSpec.describe 'Google::CallbacksController', type: :request do
get google_callback_url, params: { code: code } get google_callback_url, params: { code: code }
expect(response).to redirect_to '/' expect(response).to redirect_to(/#{Regexp.escape("#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/app/accounts/#{account.id}/settings/inboxes/new/email")}/)
expect(response.location).to include('error=')
expect(Redis::Alfred.get(cache_key).to_i).to eq account.id expect(Redis::Alfred.get(cache_key).to_i).to eq account.id
end end
end end
@@ -82,7 +82,7 @@ RSpec.describe 'Public Articles API', type: :request do
get "/hc/#{portal.slug}/articles/#{article.slug}" get "/hc/#{portal.slug}/articles/#{article.slug}"
expect(response).to have_http_status(:success) expect(response).to have_http_status(:success)
expect(response.body).to include(ChatwootMarkdownRenderer.new(article.content).render_article) expect(response.body).to include(ChatwootMarkdownRenderer.new(article.content).render_article)
expect(article.reload.views).to eq 1 expect(article.reload.views).to eq 0 # View count should not increment on show
end end
it 'does not increment the view count if the article is not published' do it 'does not increment the view count if the article is not published' do
@@ -98,4 +98,42 @@ RSpec.describe 'Public Articles API', type: :request do
expect(response).to have_http_status(:success) expect(response).to have_http_status(:success)
end end
end end
describe 'GET /public/api/v1/portals/:slug/articles/:slug.png (tracking pixel)' do
it 'serves a PNG image and increments view count for published article' do
get "/hc/#{portal.slug}/articles/#{article.slug}.png"
expect(response).to have_http_status(:success)
expect(response.headers['Content-Type']).to eq('image/png')
expect(response.headers['Cache-Control']).to include('max-age=86400')
expect(response.headers['Cache-Control']).to include('private')
expect(article.reload.views).to eq 1
end
it 'serves a PNG image but does not increment view count for draft article' do
draft_article = create(:article, category: category, status: :draft, portal: portal, account_id: account.id, author_id: agent.id, views: 0)
get "/hc/#{portal.slug}/articles/#{draft_article.slug}.png"
expect(response).to have_http_status(:success)
expect(response.headers['Content-Type']).to eq('image/png')
expect(response.headers['Cache-Control']).to include('max-age=86400')
expect(response.headers['Cache-Control']).to include('private')
expect(draft_article.reload.views).to eq 0
end
it 'returns 404 if article does not exist' do
get "/hc/#{portal.slug}/articles/non-existent-article.png"
expect(response).to have_http_status(:not_found)
end
it 'sets proper cache headers for performance' do
get "/hc/#{portal.slug}/articles/#{article.slug}.png"
expect(response.headers['Cache-Control']).to include('max-age=86400')
expect(response.headers['Cache-Control']).to include('private')
expect(response.headers['Content-Type']).to eq('image/png')
end
end
end end
@@ -0,0 +1,26 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Enterprise::CsatSurveyResponsePolicy', type: :policy do
subject(:csat_policy) { CsatSurveyResponsePolicy }
let(:account) { create(:account) }
let(:csat_survey_response) { create(:csat_survey_response, account: account) }
# Create a custom role with report_manage permission
let(:custom_role) { create(:custom_role, account: account, permissions: ['report_manage']) }
let(:agent_with_role) { create(:user) } # Create without account
let(:agent_with_role_account_user) do
create(:account_user, user: agent_with_role, account: account, role: :agent, custom_role: custom_role)
end
let(:agent_with_role_context) do
{ user: agent_with_role, account: account, account_user: agent_with_role_account_user }
end
permissions :index?, :metrics?, :download? do
context 'when agent with report_manage permission' do
it { expect(csat_policy).to permit(agent_with_role_context, csat_survey_response) }
end
end
end
-14
View File
@@ -435,20 +435,6 @@ RSpec.describe Conversation do
end end
end end
describe '#create_csat_not_sent_activity_message' do
subject(:create_csat_not_sent_activity_message) { conversation.create_csat_not_sent_activity_message }
let(:conversation) { create(:conversation) }
it 'creates CSAT not sent activity message' do
create_csat_not_sent_activity_message
expect(Conversations::ActivityMessageJob)
.to(have_been_enqueued.at_least(:once).with(conversation, { account_id: conversation.account_id, inbox_id: conversation.inbox_id,
message_type: :activity,
content: 'CSAT survey not sent due to outgoing message restrictions' }))
end
end
describe 'unread_messages' do describe 'unread_messages' do
subject(:unread_messages) { conversation.unread_messages } subject(:unread_messages) { conversation.unread_messages }
@@ -0,0 +1,25 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe CsatSurveyResponsePolicy, type: :policy do
subject(:csat_policy) { described_class }
let(:account) { create(:account) }
let(:administrator) { create(:user, :administrator, account: account) }
let(:agent) { create(:user, account: account) }
let(:csat_survey_response) { create(:csat_survey_response, account: account) }
let(:administrator_context) { { user: administrator, account: account, account_user: account.account_users.first } }
let(:agent_context) { { user: agent, account: account, account_user: account.account_users.last } }
permissions :index?, :metrics?, :download? do
context 'when administrator' do
it { expect(csat_policy).to permit(administrator_context, csat_survey_response) }
end
context 'when agent' do
it { expect(csat_policy).not_to permit(agent_context, csat_survey_response) }
end
end
end
@@ -121,9 +121,8 @@ describe MessageTemplates::HookExecutionService do
create(:message, conversation: conversation, message_type: 'incoming') create(:message, conversation: conversation, message_type: 'incoming')
end end
it 'calls ::MessageTemplates::Template::CsatSurvey when a conversation is resolved in an inbox with survey enabled and can reply' do it 'calls ::MessageTemplates::Template::CsatSurvey when a conversation is resolved in an inbox with survey enabled' do
conversation.inbox.update(csat_survey_enabled: true) conversation.inbox.update(csat_survey_enabled: true)
allow(conversation).to receive(:can_reply?).and_return(true)
conversation.resolved! conversation.resolved!
Conversations::ActivityMessageJob.perform_now(conversation, Conversations::ActivityMessageJob.perform_now(conversation,
@@ -173,32 +172,6 @@ describe MessageTemplates::HookExecutionService do
expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new).with(conversation: conversation) expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new).with(conversation: conversation)
expect(csat_survey).not_to have_received(:perform) expect(csat_survey).not_to have_received(:perform)
end end
it 'will not call ::MessageTemplates::Template::CsatSurvey if cannot reply' do
conversation.inbox.update(csat_survey_enabled: true)
allow(conversation).to receive(:can_reply?).and_return(false)
conversation.resolved!
Conversations::ActivityMessageJob.perform_now(conversation,
{ account_id: conversation.account_id, inbox_id: conversation.inbox_id, message_type: :activity,
content: 'Conversation marked resolved!!' })
expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new).with(conversation: conversation)
expect(csat_survey).not_to have_received(:perform)
end
it 'creates activity message when CSAT not sent due to messaging window restriction' do
conversation.inbox.update(csat_survey_enabled: true)
allow(conversation).to receive(:can_reply?).and_return(false)
allow(conversation).to receive(:create_csat_not_sent_activity_message)
conversation.resolved!
Conversations::ActivityMessageJob.perform_now(conversation,
{ account_id: conversation.account_id, inbox_id: conversation.inbox_id, message_type: :activity,
content: 'Conversation marked resolved!!' })
expect(conversation).to have_received(:create_csat_not_sent_activity_message)
end
end end
context 'when it is after working hours' do context 'when it is after working hours' do