Compare commits

..
Author SHA1 Message Date
Muhsin KelothandGitHub 7f9e94af2e Merge branch 'develop' into feat/github-oauth 2025-06-12 12:00:51 +05:30
Muhsin Keloth cf73ba195e feat: add github auth 2025-06-12 11:55:57 +05:30
201 changed files with 676 additions and 3011 deletions
+4 -8
View File
@@ -172,8 +172,6 @@ GEM
bundler (>= 1.2.0, < 3)
thor (~> 1.0)
byebug (11.1.3)
childprocess (5.1.0)
logger (~> 1.5)
climate_control (1.2.0)
coderay (1.1.3)
commonmarker (0.23.10)
@@ -435,12 +433,10 @@ GEM
json (>= 1.8)
rexml
language_server-protocol (3.17.0.5)
launchy (3.1.1)
launchy (2.5.2)
addressable (~> 2.8)
childprocess (~> 5.0)
logger (~> 1.6)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
letter_opener (1.8.1)
launchy (>= 2.2, < 3)
line-bot-api (1.28.0)
lint_roller (1.1.0)
liquid (5.4.0)
@@ -567,7 +563,7 @@ GEM
method_source (~> 1.0)
pry-rails (0.3.9)
pry (>= 0.10.4)
public_suffix (6.0.2)
public_suffix (6.0.0)
puma (6.4.3)
nio4r (~> 2.0)
pundit (2.3.0)
+1 -12
View File
@@ -10,8 +10,7 @@ function toggleSecretField(e) {
if (!textElement) return;
if (textElement.dataset.secretMasked === 'false') {
const maskedLength = secretField.dataset.secretText?.length || 10;
textElement.textContent = '•'.repeat(maskedLength);
textElement.textContent = '•'.repeat(10);
textElement.dataset.secretMasked = 'true';
toggler.querySelector('svg use').setAttribute('xlink:href', '#eye-show');
@@ -33,13 +32,3 @@ function copySecretField(e) {
navigator.clipboard.writeText(secretField.dataset.secretText);
}
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.cell-data__secret-field').forEach(field => {
const span = field.querySelector('[data-secret-masked]');
if (span && span.dataset.secretMasked === 'true') {
const len = field.dataset.secretText?.length || 10;
span.textContent = '•'.repeat(len);
}
});
});
@@ -46,25 +46,17 @@
.cell-data__secret-field {
align-items: center;
color: $hint-grey;
display: flex;
span {
flex: 0 0 auto;
flex: 1;
}
[data-secret-toggler],
[data-secret-copier] {
background: transparent;
border: 0;
color: inherit;
margin-left: 0.5rem;
padding: 0;
button {
margin-left: 5px;
svg {
fill: currentColor;
height: 1.25rem;
width: 1.25rem;
}
}
}
@@ -31,7 +31,7 @@ class V2::Reports::LabelSummaryBuilder < V2::Reports::BaseSummaryBuilder
resolved_counts: fetch_resolved_counts(conversation_filter),
resolution_metrics: fetch_metrics(conversation_filter, 'conversation_resolved', use_business_hours),
first_response_metrics: fetch_metrics(conversation_filter, 'first_response', use_business_hours),
reply_metrics: fetch_metrics(conversation_filter, 'reply_time', use_business_hours)
reply_metrics: fetch_metrics(conversation_filter, 'reply', use_business_hours)
}
end
@@ -63,9 +63,7 @@ class V2::Reports::LabelSummaryBuilder < V2::Reports::BaseSummaryBuilder
end
def fetch_resolved_counts(conversation_filter)
# since the base query is ActsAsTaggableOn,
# the status :resolved won't automatically be converted to integer status
fetch_counts(conversation_filter.merge(status: Conversation.statuses[:resolved]))
fetch_counts(conversation_filter.merge(status: :resolved))
end
def fetch_counts(conversation_filter)
@@ -1,23 +1,32 @@
class Api::V1::Accounts::Google::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController
class Api::V1::Accounts::Google::AuthorizationsController < Api::V1::Accounts::BaseController
include GoogleConcern
before_action :check_authorization
def create
email = params[:authorization][:email]
redirect_url = google_client.auth_code.authorize_url(
{
redirect_uri: "#{base_url}/google/callback",
scope: scope,
scope: 'email profile https://mail.google.com/',
response_type: 'code',
prompt: 'consent', # the oauth flow does not return a refresh token, this is supposed to fix it
access_type: 'offline', # the default is 'online'
state: state,
client_id: GlobalConfigService.load('GOOGLE_OAUTH_CLIENT_ID', nil)
}
)
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 }
else
render json: { success: false }, status: :unprocessable_entity
end
end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end
@@ -81,15 +81,11 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
def create_channel
return unless allowed_channel_types.include?(permitted_params[:channel][:type])
return unless %w[web_widget api email line telegram whatsapp sms].include?(permitted_params[:channel][:type])
account_channels_method.create!(permitted_params(channel_type_from_params::EDITABLE_ATTRS)[:channel].except(:type))
end
def allowed_channel_types
%w[web_widget api email line telegram whatsapp sms]
end
def update_inbox_working_hours
@inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours]
end
@@ -1,6 +1,7 @@
class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController
class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts::BaseController
include InstagramConcern
include Instagram::IntegrationHelper
before_action :check_authorization
def create
# https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#step-1--get-authorization
@@ -20,4 +21,10 @@ class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts
render json: { success: false }, status: :unprocessable_entity
end
end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end
@@ -0,0 +1,13 @@
class Api::V1::Accounts::Integrations::GithubController < Api::V1::Accounts::BaseController
before_action :check_authorization
def show
@github_integration = Current.account.hooks.find_by(app_id: 'github')
end
private
def check_authorization
authorize ::Webhook, :show?
end
end
@@ -1,5 +1,5 @@
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController
before_action :fetch_conversation, only: [:create_issue, :link_issue, :unlink_issue, :linked_issues]
before_action :fetch_conversation, only: [:link_issue, :linked_issues]
before_action :fetch_hook, only: [:destroy]
def destroy
@@ -31,12 +31,6 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
if issue[:error]
render json: { error: issue[:error] }, status: :unprocessable_entity
else
Linear::ActivityMessageService.new(
conversation: @conversation,
action_type: :issue_created,
issue_data: { id: issue[:data][:identifier] },
user: Current.user
).perform
render json: issue[:data], status: :ok
end
end
@@ -48,30 +42,17 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
if issue[:error]
render json: { error: issue[:error] }, status: :unprocessable_entity
else
Linear::ActivityMessageService.new(
conversation: @conversation,
action_type: :issue_linked,
issue_data: { id: issue_id },
user: Current.user
).perform
render json: issue[:data], status: :ok
end
end
def unlink_issue
link_id = permitted_params[:link_id]
issue_id = permitted_params[:issue_id]
issue = linear_processor_service.unlink_issue(link_id)
if issue[:error]
render json: { error: issue[:error] }, status: :unprocessable_entity
else
Linear::ActivityMessageService.new(
conversation: @conversation,
action_type: :issue_unlinked,
issue_data: { id: issue_id },
user: Current.user
).perform
render json: issue[:data], status: :ok
end
end
@@ -1,14 +0,0 @@
class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::BaseController
before_action :fetch_hook, only: [:destroy]
def destroy
@hook.destroy!
head :ok
end
private
def fetch_hook
@hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'notion')
end
end
@@ -1,19 +1,28 @@
class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController
class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts::BaseController
include MicrosoftConcern
before_action :check_authorization
def create
email = params[:authorization][:email]
redirect_url = microsoft_client.auth_code.authorize_url(
{
redirect_uri: "#{base_url}/microsoft/callback",
scope: scope,
state: state,
scope: 'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send openid profile',
prompt: 'consent'
}
)
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 }
else
render json: { success: false }, status: :unprocessable_entity
end
end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end
@@ -1,21 +0,0 @@
class Api::V1::Accounts::Notion::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController
include NotionConcern
def create
redirect_url = notion_client.auth_code.authorize_url(
{
redirect_uri: "#{base_url}/notion/callback",
response_type: 'code',
owner: 'user',
state: state,
client_id: GlobalConfigService.load('NOTION_CLIENT_ID', nil)
}
)
if redirect_url
render json: { success: true, url: redirect_url }
else
render json: { success: false }, status: :unprocessable_entity
end
end
end
@@ -1,23 +0,0 @@
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
+2 -2
View File
@@ -14,7 +14,7 @@ module GoogleConcern
private
def scope
'email profile https://mail.google.com/'
def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end
end
@@ -15,7 +15,7 @@ module MicrosoftConcern
private
def scope
'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send openid profile email'
def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end
end
@@ -1,21 +0,0 @@
module NotionConcern
extend ActiveSupport::Concern
def notion_client
app_id = GlobalConfigService.load('NOTION_CLIENT_ID', nil)
app_secret = GlobalConfigService.load('NOTION_CLIENT_SECRET', nil)
::OAuth2::Client.new(app_id, app_secret, {
site: 'https://api.notion.com',
authorize_url: 'https://api.notion.com/v1/oauth/authorize',
token_url: 'https://api.notion.com/v1/oauth/token',
auth_scheme: :basic_auth
})
end
private
def scope
''
end
end
@@ -0,0 +1,70 @@
class Github::CallbacksController < ApplicationController
include Github::IntegrationHelper
before_action :authenticate_user!
before_action :set_account
def show
if params[:error].present?
redirect_to app_account_integrations_path(account_id: @account.id), alert: params[:error_description]
return
end
if params[:code].present?
response = exchange_code_for_token(params[:code])
if response[:access_token].present?
hook = @account.hooks.find_or_initialize_by(app_id: 'github')
hook.access_token = response[:access_token]
hook.status = 'enabled'
if hook.save
redirect_to app_account_integrations_path(account_id: @account.id), notice: 'GitHub integration connected successfully!'
else
redirect_to app_account_integrations_path(account_id: @account.id), alert: 'Failed to save GitHub integration'
end
else
redirect_to app_account_integrations_path(account_id: @account.id), alert: 'Failed to get access token from GitHub'
end
else
redirect_to app_account_integrations_path(account_id: @account.id), alert: 'GitHub authorization failed'
end
end
private
def set_account
@account = Current.user.accounts.find(params[:account_id]) if params[:account_id].present?
@account ||= Current.user.accounts.first
return if @account
redirect_to root_path, alert: 'Account not found'
end
def exchange_code_for_token(code)
client_id = GlobalConfigService.load('GITHUB_CLIENT_ID', nil)
client_secret = GlobalConfigService.load('GITHUB_CLIENT_SECRET', nil)
return {} unless client_id && client_secret
response = HTTParty.post('https://github.com/login/oauth/access_token', {
body: {
client_id: client_id,
client_secret: client_secret,
code: code
},
headers: {
'Accept' => 'application/json'
}
})
if response.success?
JSON.parse(response.body).with_indifferent_access
else
{}
end
rescue StandardError => e
Rails.logger.error "GitHub OAuth error: #{e.message}"
{}
end
end
@@ -1,36 +0,0 @@
class Notion::CallbacksController < OauthCallbackController
include NotionConcern
private
def provider_name
'notion'
end
def oauth_client
notion_client
end
def handle_response
hook = account.hooks.new(
access_token: parsed_body['access_token'],
status: 'enabled',
app_id: 'notion',
settings: {
token_type: parsed_body['token_type'],
workspace_name: parsed_body['workspace_name'],
workspace_id: parsed_body['workspace_id'],
workspace_icon: parsed_body['workspace_icon'],
bot_id: parsed_body['bot_id'],
owner: parsed_body['owner']
}
)
hook.save!
redirect_to notion_redirect_uri
end
def notion_redirect_uri
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/notion"
end
end
+8 -8
View File
@@ -6,6 +6,7 @@ class OauthCallbackController < ApplicationController
)
handle_response
::Redis::Alfred.delete(cache_key)
rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception
redirect_to '/'
@@ -63,6 +64,10 @@ class OauthCallbackController < ApplicationController
raise NotImplementedError
end
def cache_key
"#{provider_name}::#{users_data['email'].downcase}"
end
def create_channel_with_inbox
ActiveRecord::Base.transaction do
channel_email = Channel::Email.create!(email: users_data['email'], account: account)
@@ -81,17 +86,12 @@ class OauthCallbackController < ApplicationController
decoded_token[0]
end
def account_from_signed_id
raise ActionController::BadRequest, 'Missing state variable' if params[:state].blank?
account = GlobalID::Locator.locate_signed(params[:state])
raise 'Invalid or expired state' if account.nil?
account
def account_id
::Redis::Alfred.get(cache_key)
end
def account
@account ||= account_from_signed_id
@account ||= Account.find(account_id)
end
# Fallback name, for when name field is missing from users_data
@@ -7,11 +7,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
def index
@articles = @portal.articles.published.includes(:category, :author)
@articles = @articles.where(locale: permitted_params[:locale]) if permitted_params[:locale].present?
@articles_count = @articles.count
search_articles
order_by_sort_param
limit_results
@@ -39,8 +39,8 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'],
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET],
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT]
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
'github' => %w[GITHUB_CLIENT_ID GITHUB_CLIENT_SECRET]
}
@allowed_configs = mapping.fetch(@config, %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS])
+71
View File
@@ -0,0 +1,71 @@
module Github::IntegrationHelper
def github_integration_url(account_id)
"#{ENV.fetch('FRONTEND_URL', nil)}/github/callback?account_id=#{account_id}"
end
def github_oauth_url(account_id)
client_id = GlobalConfigService.load('GITHUB_CLIENT_ID', nil)
return nil unless client_id
params = {
client_id: client_id,
redirect_uri: github_integration_url(account_id),
scope: 'repo',
state: generate_state_token(account_id)
}
"https://github.com/login/oauth/authorize?#{params.to_query}"
end
def github_configured?
GlobalConfigService.load('GITHUB_CLIENT_ID', nil).present? &&
GlobalConfigService.load('GITHUB_CLIENT_SECRET', nil).present?
end
def github_integration_enabled?(account)
return false unless github_configured?
account.hooks.exists?(app_id: 'github', status: 'enabled')
end
def github_repositories(access_token)
return [] unless access_token
response = HTTParty.get('https://api.github.com/user/repos', {
headers: {
'Authorization' => "token #{access_token}",
'Accept' => 'application/vnd.github.v3+json'
},
query: {
per_page: 100,
sort: 'updated'
}
})
if response.success?
JSON.parse(response.body)
else
[]
end
rescue StandardError => e
Rails.logger.error "GitHub API error: #{e.message}"
[]
end
private
def generate_state_token(account_id)
payload = {
account_id: account_id,
timestamp: Time.current.to_i
}
JWT.encode(payload, Rails.application.secret_key_base, 'HS256')
end
def verify_state_token(state)
JWT.decode(state, Rails.application.secret_key_base, true, { algorithm: 'HS256' })
rescue JWT::DecodeError
nil
end
end
@@ -33,11 +33,9 @@ class LinearAPI extends ApiClient {
);
}
unlinkIssue(linkId, issueIdentifier, conversationId) {
unlinkIssue(linkId) {
return axios.post(`${this.url}/unlink_issue`, {
link_id: linkId,
issue_id: issueIdentifier,
conversation_id: conversationId,
});
}
@@ -1,14 +0,0 @@
/* global axios */
import ApiClient from './ApiClient';
class NotionOAuthClient extends ApiClient {
constructor() {
super('notion', { accountScoped: true });
}
generateAuthorization() {
return axios.post(`${this.url}/authorization`);
}
}
export default new NotionOAuthClient();
@@ -91,19 +91,6 @@ describe('#linearAPI', () => {
issueData
);
});
it('creates a valid request with conversation_id', () => {
const issueData = {
title: 'New Issue',
description: 'Issue description',
conversation_id: 123,
};
LinearAPIClient.createIssue(issueData);
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/linear/create_issue',
issueData
);
});
});
describe('link_issue', () => {
@@ -133,18 +120,6 @@ describe('#linearAPI', () => {
}
);
});
it('creates a valid request with title', () => {
LinearAPIClient.link_issue(1, 'ENG-123', 'Sample Issue');
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/linear/link_issue',
{
issue_id: 'ENG-123',
conversation_id: 1,
title: 'Sample Issue',
}
);
});
});
describe('getLinkedIssue', () => {
@@ -189,26 +164,12 @@ describe('#linearAPI', () => {
window.axios = originalAxios;
});
it('creates a valid request with link_id only', () => {
LinearAPIClient.unlinkIssue('link123');
it('creates a valid request', () => {
LinearAPIClient.unlinkIssue(1);
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/linear/unlink_issue',
{
link_id: 'link123',
issue_id: undefined,
conversation_id: undefined,
}
);
});
it('creates a valid request with all parameters', () => {
LinearAPIClient.unlinkIssue('link123', 'ENG-456', 789);
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/linear/unlink_issue',
{
link_id: 'link123',
issue_id: 'ENG-456',
conversation_id: 789,
link_id: 1,
}
);
});
@@ -47,7 +47,7 @@
@apply max-w-full;
.multiselect__option {
@apply text-sm font-normal flex justify-between items-center;
@apply text-sm font-normal;
span {
@apply inline-block overflow-hidden text-ellipsis whitespace-nowrap w-fit;
@@ -58,7 +58,7 @@
}
&::after {
@apply bottom-0 flex items-center justify-center text-center relative px-1 leading-tight;
@apply bottom-0 flex items-center justify-center text-center;
}
&.multiselect__option--highlight {
@@ -74,7 +74,7 @@
}
&.multiselect__option--highlight::after {
@apply bg-transparent text-n-slate-12;
@apply bg-transparent;
}
&.multiselect__option--selected {
@@ -123,7 +123,7 @@ const handleDocumentableClick = () => {
@mouseenter="emit('hover', true)"
@mouseleave="emit('hover', false)"
>
<div v-show="selectable" class="absolute top-7 ltr:left-3 rtl:right-3">
<div v-show="selectable" class="absolute top-7 ltr:left-4 rtl:right-4">
<Checkbox v-model="modelValue" />
</div>
<div class="flex relative justify-between w-full gap-1">
@@ -19,7 +19,6 @@ const isConversationRoute = computed(() => {
'conversation_through_mentions',
'conversation_through_unattended',
'conversation_through_participating',
'inbox_view_conversation',
];
return CONVERSATION_ROUTES.includes(route.name);
});
@@ -13,7 +13,6 @@ export function useChannelIcon(inbox) {
'Channel::WebWidget': 'i-ri-global-fill',
'Channel::Whatsapp': 'i-ri-whatsapp-fill',
'Channel::Instagram': 'i-ri-instagram-fill',
'Channel::Voice': 'i-ri-phone-fill',
};
const providerIconMap = {
@@ -19,12 +19,6 @@ describe('useChannelIcon', () => {
expect(icon).toBe('i-ri-whatsapp-fill');
});
it('returns correct icon for Voice channel', () => {
const inbox = { channel_type: 'Channel::Voice' };
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-ri-phone-fill');
});
describe('Email channel', () => {
it('returns mail icon for generic email channel', () => {
const inbox = { channel_type: 'Channel::Email' };
@@ -1,21 +1,51 @@
<script setup>
import { computed, ref, onMounted, nextTick, getCurrentInstance } from 'vue';
import { computed, ref, onMounted, nextTick } from 'vue';
const props = defineProps({
modelValue: { type: [String, Number], default: '' },
type: { type: String, default: 'text' },
customInputClass: { type: [String, Object, Array], default: '' },
placeholder: { type: String, default: '' },
label: { type: String, default: '' },
id: { type: String, default: '' },
message: { type: String, default: '' },
disabled: { type: Boolean, default: false },
modelValue: {
type: [String, Number],
default: '',
},
type: {
type: String,
default: 'text',
},
customInputClass: {
type: [String, Object, Array],
default: '',
},
placeholder: {
type: String,
default: '',
},
label: {
type: String,
default: '',
},
id: {
type: String,
default: '',
},
message: {
type: String,
default: '',
},
disabled: {
type: Boolean,
default: false,
},
messageType: {
type: String,
default: 'info',
validator: value => ['info', 'error', 'success'].includes(value),
},
min: { type: String, default: '' },
autofocus: { type: Boolean, default: false },
min: {
type: String,
default: '',
},
autofocus: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
@@ -26,10 +56,6 @@ const emit = defineEmits([
'enter',
]);
// Generate a unique ID per component instance when `id` prop is not provided.
const { uid } = getCurrentInstance();
const uniqueId = computed(() => props.id || `input-${uid}`);
const isFocused = ref(false);
const inputRef = ref(null);
@@ -85,7 +111,7 @@ onMounted(() => {
<div class="relative flex flex-col min-w-0 gap-1">
<label
v-if="label"
:for="uniqueId"
:for="id"
class="mb-0.5 text-sm font-medium text-n-slate-12"
>
{{ label }}
@@ -93,7 +119,7 @@ onMounted(() => {
<!-- Added prefix slot to allow adding icons to the input -->
<slot name="prefix" />
<input
:id="uniqueId"
:id="id"
ref="inputRef"
:value="modelValue"
:class="[
@@ -379,7 +379,7 @@ const shouldRenderMessage = computed(() => {
function openContextMenu(e) {
const shouldSkipContextMenu =
e.target?.classList.contains('skip-context-menu') ||
['a', 'img'].includes(e.target?.tagName.toLowerCase());
e.target?.tagName.toLowerCase() === 'a';
if (shouldSkipContextMenu || getSelection().toString()) {
return;
}
@@ -14,8 +14,7 @@ const fromEmail = computed(() => {
});
const toEmail = computed(() => {
const { toEmails, email } = contentAttributes.value;
return email?.to ?? toEmails ?? [];
return contentAttributes.value?.email?.to ?? [];
});
const ccEmail = computed(() => {
@@ -17,7 +17,7 @@ export default {
<button
class="bg-white dark:bg-slate-900 cursor-pointer flex flex-col justify-end transition-all duration-200 ease-in -m-px py-4 px-0 items-center border border-solid border-slate-25 dark:border-slate-800 hover:border-woot-500 dark:hover:border-woot-500 hover:shadow-md hover:z-50 disabled:opacity-60"
>
<img :src="src" :alt="title" draggable="false" class="w-1/2 my-4 mx-auto" />
<img :src="src" :alt="title" class="w-1/2 my-4 mx-auto" />
<h3
class="text-slate-800 dark:text-slate-100 text-base text-center capitalize"
>
@@ -756,7 +756,6 @@ function toggleSelectAll(check) {
}
useEmitter('fetch_conversation_stats', () => {
if (hasAppliedFiltersOrActiveFolders.value) return;
store.dispatch('conversationStats/get', conversationFilters.value);
});
@@ -855,7 +854,7 @@ watch(conversationFilters, (newVal, oldVal) => {
:active-status="activeStatus"
:is-on-expanded-layout="isOnExpandedLayout"
:conversation-stats="conversationStats"
:is-list-loading="chatListLoading && !conversationList.length"
:is-list-loading="chatListLoading"
@add-folders="onClickOpenAddFoldersModal"
@delete-folders="onClickOpenDeleteFoldersModal"
@filters-modal="onToggleAdvanceFiltersModal"
@@ -21,7 +21,7 @@ const props = defineProps({
const isRelaxed = computed(() => props.type === 'relaxed');
const headerClass = computed(() =>
isRelaxed.value
? 'ltr:first:rounded-bl-lg ltr:first:rounded-tl-lg ltr:last:rounded-br-lg ltr:last:rounded-tr-lg rtl:first:rounded-br-lg rtl:first:rounded-tr-lg rtl:last:rounded-bl-lg rtl:last:rounded-tl-lg'
? 'first:rounded-bl-lg first:rounded-tl-lg last:rounded-br-lg last:rounded-tr-lg'
: ''
);
</script>
@@ -41,10 +41,6 @@ export default {
);
}
if (key === 'voice') {
return this.enabledFeatures.channel_voice;
}
return [
'website',
'twilio',
@@ -54,7 +50,6 @@ export default {
'telegram',
'line',
'instagram',
'voice',
].includes(key);
},
},
@@ -183,18 +183,13 @@ const createIssue = async () => {
state_id: formState.stateId || undefined,
priority: formState.priority || undefined,
label_ids: formState.labelId ? [formState.labelId] : undefined,
conversation_id: props.conversationId,
};
try {
isCreating.value = true;
const response = await LinearAPI.createIssue(payload);
const { identifier: issueIdentifier } = response.data;
await LinearAPI.link_issue(
props.conversationId,
issueIdentifier,
props.title
);
const { id: issueId } = response.data;
await LinearAPI.link_issue(props.conversationId, issueId, props.title);
useAlert(t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE_SUCCESS'));
useTrack(LINEAR_EVENTS.CREATE_ISSUE);
onClose();
@@ -46,9 +46,9 @@ const loadLinkedIssues = async () => {
}
};
const unlinkIssue = async (linkId, issueIdentifier) => {
const unlinkIssue = async linkId => {
try {
await LinearAPI.unlinkIssue(linkId, issueIdentifier, props.conversationId);
await LinearAPI.unlinkIssue(linkId);
useTrack(LINEAR_EVENTS.UNLINK_ISSUE);
linkedIssues.value = linkedIssues.value.filter(
issue => issue.id !== linkId
@@ -110,7 +110,7 @@ onMounted(() => {
<LinearIssueItem
v-for="linkedIssue in linkedIssues"
:key="linkedIssue.id"
class="px-4 pt-3 pb-4 border-b border-n-weak last:border-b-0"
class="pt-3 px-4 pb-4 border-b border-n-weak last:border-b-0"
:linked-issue="linkedIssue"
@unlink-issue="unlinkIssue"
/>
@@ -14,8 +14,6 @@ const props = defineProps({
const emit = defineEmits(['unlinkIssue']);
const { linkedIssue } = props;
const priorityMap = {
1: 'Urgent',
2: 'High',
@@ -23,7 +21,7 @@ const priorityMap = {
4: 'Low',
};
const issue = computed(() => linkedIssue.issue);
const issue = computed(() => props.linkedIssue.issue);
const assignee = computed(() => {
const assigneeDetails = issue.value.assignee;
@@ -39,7 +37,7 @@ const labels = computed(() => issue.value.labels?.nodes || []);
const priorityLabel = computed(() => priorityMap[issue.value.priority]);
const unlinkIssue = () => {
emit('unlinkIssue', linkedIssue.id, linkedIssue.issue.identifier);
emit('unlinkIssue', props.linkedIssue.id);
};
</script>
@@ -63,7 +63,7 @@ const onSearch = async value => {
isFetching.value = true;
const response = await LinearAPI.searchIssues(value);
issues.value = response.data.map(issue => ({
id: issue.identifier,
id: issue.id,
name: `${issue.identifier} ${issue.title}`,
icon: 'status',
iconColor: issue.state.color,
@@ -125,10 +125,6 @@ export const useInbox = () => {
return channelType.value === INBOX_TYPES.INSTAGRAM;
});
const isAVoiceChannel = computed(() => {
return channelType.value === INBOX_TYPES.VOICE;
});
return {
inbox,
isAFacebookInbox,
@@ -146,6 +142,5 @@ export const useInbox = () => {
is360DialogWhatsAppChannel,
isAnEmailChannel,
isAnInstagramChannel,
isAVoiceChannel,
};
};
-10
View File
@@ -10,7 +10,6 @@ export const INBOX_TYPES = {
LINE: 'Channel::Line',
SMS: 'Channel::Sms',
INSTAGRAM: 'Channel::Instagram',
VOICE: 'Channel::Voice',
};
const INBOX_ICON_MAP_FILL = {
@@ -23,7 +22,6 @@ const INBOX_ICON_MAP_FILL = {
[INBOX_TYPES.TELEGRAM]: 'i-ri-telegram-fill',
[INBOX_TYPES.LINE]: 'i-ri-line-fill',
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-fill',
[INBOX_TYPES.VOICE]: 'i-ri-phone-fill',
};
const DEFAULT_ICON_FILL = 'i-ri-chat-1-fill';
@@ -38,7 +36,6 @@ const INBOX_ICON_MAP_LINE = {
[INBOX_TYPES.TELEGRAM]: 'i-ri-telegram-line',
[INBOX_TYPES.LINE]: 'i-ri-line-line',
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-line',
[INBOX_TYPES.VOICE]: 'i-ri-phone-line',
};
const DEFAULT_ICON_LINE = 'i-ri-chat-1-line';
@@ -50,7 +47,6 @@ export const getInboxSource = (type, phoneNumber, inbox) => {
case INBOX_TYPES.TWILIO:
case INBOX_TYPES.WHATSAPP:
case INBOX_TYPES.VOICE:
return phoneNumber || '';
case INBOX_TYPES.EMAIL:
@@ -89,9 +85,6 @@ export const getReadableInboxByType = (type, phoneNumber) => {
case INBOX_TYPES.LINE:
return 'line';
case INBOX_TYPES.VOICE:
return 'voice';
default:
return 'chat';
}
@@ -131,9 +124,6 @@ export const getInboxClassByType = (type, phoneNumber) => {
case INBOX_TYPES.INSTAGRAM:
return 'brand-instagram';
case INBOX_TYPES.VOICE:
return 'phone';
default:
return 'chat';
}
@@ -268,46 +268,6 @@
"ERROR_MESSAGE": "We were not able to save the WhatsApp channel"
}
},
"VOICE": {
"TITLE": "Voice Channel",
"DESC": "Integrate Twilio Voice and start supporting your customers via phone calls.",
"PHONE_NUMBER": {
"LABEL": "Phone Number",
"PLACEHOLDER": "Enter your phone number (e.g. +1234567890)",
"ERROR": "Please provide a valid phone number in E.164 format (e.g. +1234567890)"
},
"TWILIO": {
"ACCOUNT_SID": {
"LABEL": "Account SID",
"PLACEHOLDER": "Enter your Twilio Account SID",
"REQUIRED": "Account SID is required"
},
"AUTH_TOKEN": {
"LABEL": "Auth Token",
"PLACEHOLDER": "Enter your Twilio Auth Token",
"REQUIRED": "Auth Token is required"
},
"API_KEY_SID": {
"LABEL": "API Key SID",
"PLACEHOLDER": "Enter your Twilio API Key SID",
"REQUIRED": "API Key SID is required"
},
"API_KEY_SECRET": {
"LABEL": "API Key Secret",
"PLACEHOLDER": "Enter your Twilio API Key Secret",
"REQUIRED": "API Key Secret is required"
},
"TWIML_APP_SID": {
"LABEL": "TwiML App SID",
"PLACEHOLDER": "Enter your Twilio TwiML App SID (starts with AP)",
"REQUIRED": "TwiML App SID is required"
}
},
"SUBMIT_BUTTON": "Create Voice Channel",
"API": {
"ERROR_MESSAGE": "We were not able to create the voice channel"
}
},
"API_CHANNEL": {
"TITLE": "API Channel",
"DESC": "Integrate with API channel and start supporting your customers.",
@@ -829,8 +789,7 @@
"TELEGRAM": "Telegram",
"LINE": "Line",
"API": "API Channel",
"INSTAGRAM": "Instagram",
"VOICE": "Voice"
"INSTAGRAM": "Instagram"
}
}
}
@@ -329,12 +329,26 @@
"BUTTON_TEXT": "Connect Linear workspace"
}
},
"NOTION": {
"DELETE": {
"TITLE": "Are you sure you want to delete the Notion integration?",
"MESSAGE": "Deleting this integration will remove access to your Notion workspace and stop all related functionality.",
"CONFIRM": "Yes, delete",
"CANCEL": "Cancel"
"GITHUB": {
"TITLE": "GitHub Integration",
"DESCRIPTION": "Connect your GitHub repositories to create issues directly from customer conversations. This integration helps you track bugs, feature requests, and development tasks seamlessly.",
"NOT_CONFIGURED": {
"TITLE": "GitHub Integration Not Configured",
"DESCRIPTION": "Contact your administrator to configure GitHub OAuth settings."
},
"CONNECT": {
"TITLE": "Connect GitHub",
"DESCRIPTION": "Connect your GitHub account to start creating issues from conversations.",
"BUTTON": "Connect GitHub"
},
"CONNECTED": {
"TITLE": "GitHub Connected",
"DESCRIPTION": "Your GitHub account is successfully connected. You can now create issues from conversations."
},
"DISCONNECT": {
"BUTTON": "Disconnect",
"SUCCESS": "GitHub integration disconnected successfully",
"ERROR": "Failed to disconnect GitHub integration"
}
}
},
@@ -545,8 +559,6 @@
"CONVERSATION": "Conversation #{id}"
},
"SELECTED": "{count} selected",
"SELECT_ALL": "Select all ({count})",
"UNSELECT_ALL": "Unselect all ({count})",
"BULK_APPROVE_BUTTON": "Approve",
"BULK_DELETE_BUTTON": "Delete",
"BULK_APPROVE": {
@@ -152,7 +152,7 @@
"ATTRIBUTES": {
"MESSAGE_TYPE": "Tipo da Mensagem",
"MESSAGE_CONTAINS": "A mensagem contém",
"EMAIL": "E-mail",
"EMAIL": "e-mail",
"INBOX": "Caixa de Entrada",
"CONVERSATION_LANGUAGE": "Idioma da conversa",
"PHONE_NUMBER": "Número de Telefone",
@@ -130,15 +130,15 @@ export default {
</div>
<div class="flex multiselect-wrap--medium">
<div
class="w-8 relative text-base text-slate-100 dark:text-slate-600 after:content-[''] after:h-12 after:w-0 ltr:after:left-4 rtl:after:right-4 after:absolute after:border-l after:border-solid after:border-slate-100 after:dark:border-slate-600 before:content-[''] before:h-0 before:w-4 ltr:before:left-4 rtl:before:right-4 before:top-12 before:absolute before:border-b before:border-solid before:border-slate-100 before:dark:border-slate-600"
class="w-8 relative text-base text-slate-100 dark:text-slate-600 after:content-[''] after:h-12 after:w-0 after:left-4 after:absolute after:border-l after:border-solid after:border-slate-100 after:dark:border-slate-600 before:content-[''] before:h-0 before:w-4 before:left-4 before:top-12 before:absolute before:border-b before:border-solid before:border-slate-100 before:dark:border-slate-600"
>
<fluent-icon
icon="arrow-up"
class="absolute -top-1 ltr:left-2 rtl:right-2"
class="absolute -top-1 left-2"
size="17"
/>
</div>
<div class="flex flex-col w-full ltr:pl-8 rtl:pr-8">
<div class="flex flex-col w-full">
<label class="multiselect__label">
{{ $t('MERGE_CONTACTS.PRIMARY.TITLE') }}
<woot-label
@@ -157,13 +157,6 @@ const bulkCheckbox = computed({
},
});
const buildSelectedCountLabel = computed(() => {
const count = responses.value?.length || 0;
return bulkSelectionState.value.allSelected
? t('CAPTAIN.RESPONSES.UNSELECT_ALL', { count })
: t('CAPTAIN.RESPONSES.SELECT_ALL', { count });
});
const handleCardHover = (isHovered, id) => {
hoveredCard.value = isHovered ? id : null;
};
@@ -277,11 +270,7 @@ onMounted(() => {
<template #controls>
<div
v-if="shouldShowDropdown"
class="mb-4 -mt-3 flex justify-between items-center w-fit py-1"
:class="{
'ltr:pl-3 rtl:pr-3 ltr:pr-1 rtl:pl-1 rounded-lg outline outline-1 outline-n-weak bg-n-solid-3':
bulkSelectionState.hasSelected,
}"
class="mb-4 -mt-3 flex justify-between items-center"
>
<div v-if="!bulkSelectionState.hasSelected" class="flex gap-3">
<OnClickOutside @trigger="isStatusFilterOpen = false">
@@ -317,18 +306,13 @@ onMounted(() => {
>
<div
v-if="bulkSelectionState.hasSelected"
class="flex items-center gap-3"
class="flex items-center gap-3 ltr:pl-4 rtl:pr-4"
>
<div class="flex items-center gap-3">
<div class="flex items-center gap-1.5">
<Checkbox
v-model="bulkCheckbox"
:indeterminate="bulkSelectionState.isIndeterminate"
/>
<span class="text-sm text-n-slate-12 font-medium tabular-nums">
{{ buildSelectedCountLabel }}
</span>
</div>
<div class="flex items-center gap-1.5">
<Checkbox
v-model="bulkCheckbox"
:indeterminate="bulkSelectionState.isIndeterminate"
/>
<span class="text-sm text-n-slate-10 tabular-nums">
{{
$t('CAPTAIN.RESPONSES.SELECTED', {
@@ -338,23 +322,17 @@ onMounted(() => {
</span>
</div>
<div class="h-4 w-px bg-n-strong" />
<div class="flex gap-3 items-center">
<div class="flex gap-2">
<Button
:label="$t('CAPTAIN.RESPONSES.BULK_APPROVE_BUTTON')"
sm
ghost
icon="i-lucide-check"
class="!px-1.5"
slate
@click="handleBulkApprove"
/>
<div class="h-4 w-px bg-n-strong" />
<Button
:label="$t('CAPTAIN.RESPONSES.BULK_DELETE_BUTTON')"
sm
ruby
ghost
class="!px-1.5"
icon="i-lucide-trash"
slate
@click="bulkDeleteDialog.dialogRef.open()"
/>
</div>
@@ -1,7 +1,6 @@
<script setup>
import IframeLoader from 'shared/components/IframeLoader.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import { useMapGetter } from 'dashboard/composables/store';
defineProps({
url: {
@@ -12,8 +11,6 @@ defineProps({
const emit = defineEmits(['back', 'insert']);
const isRTL = useMapGetter('accounts/isRTL');
const onBack = e => {
e.stopPropagation();
emit('back');
@@ -38,7 +35,7 @@ const onInsert = e => {
</div>
<div class="-ml-4 h-full overflow-y-auto">
<div class="w-full h-full min-h-0">
<IframeLoader :url="url" :is-rtl="isRTL" is-dir-applied />
<IframeLoader :url="url" />
</div>
</div>
@@ -71,12 +71,6 @@ export default {
FEATURE_FLAGS.AUTO_RESOLVE_CONVERSATIONS
);
},
showAudioTranscriptionConfig() {
return this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.CAPTAIN
);
},
languagesSortedByCode() {
const enabledLanguages = [...this.enabledLanguages];
return enabledLanguages.sort((l1, l2) =>
@@ -243,7 +237,7 @@ export default {
<woot-loading-state v-if="uiFlags.isFetchingItem" />
</div>
<AutoResolve v-if="showAutoResolutionConfig" />
<AudioTranscription v-if="showAudioTranscriptionConfig" />
<AudioTranscription v-if="isOnChatwootCloud" />
<AccountId />
<div v-if="!uiFlags.isFetchingItem && isOnChatwootCloud">
<AccountDelete />
@@ -10,7 +10,6 @@ import Whatsapp from './channels/Whatsapp.vue';
import Line from './channels/Line.vue';
import Telegram from './channels/Telegram.vue';
import Instagram from './channels/Instagram.vue';
import Voice from './channels/Voice.vue';
const channelViewList = {
facebook: Facebook,
@@ -23,7 +22,6 @@ const channelViewList = {
line: Line,
telegram: Telegram,
instagram: Instagram,
voice: Voice,
};
export default defineComponent({
@@ -36,7 +36,6 @@ export default {
{ key: 'telegram', name: 'Telegram' },
{ key: 'line', name: 'Line' },
{ key: 'instagram', name: 'Instagram' },
{ key: 'voice', name: 'Voice' },
];
},
...mapGetters({
@@ -1,184 +0,0 @@
<script setup>
import { reactive, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { useAlert } from 'dashboard/composables';
import { isPhoneE164 } from 'shared/helpers/Validators';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import PageHeader from '../../SettingsSubPageHeader.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const { t } = useI18n();
const store = useStore();
const router = useRouter();
const state = reactive({
phoneNumber: '',
accountSid: '',
authToken: '',
apiKeySid: '',
apiKeySecret: '',
twimlAppSid: '',
});
const uiFlags = useMapGetter('inboxes/getUIFlags');
const validationRules = {
phoneNumber: { required, isPhoneE164 },
accountSid: { required },
authToken: { required },
apiKeySid: { required },
apiKeySecret: { required },
twimlAppSid: { required },
};
const v$ = useVuelidate(validationRules, state);
const isSubmitDisabled = computed(() => v$.value.$invalid);
const formErrors = computed(() => ({
phoneNumber: v$.value.phoneNumber?.$error
? t('INBOX_MGMT.ADD.VOICE.PHONE_NUMBER.ERROR')
: '',
accountSid: v$.value.accountSid?.$error
? t('INBOX_MGMT.ADD.VOICE.TWILIO.ACCOUNT_SID.REQUIRED')
: '',
authToken: v$.value.authToken?.$error
? t('INBOX_MGMT.ADD.VOICE.TWILIO.AUTH_TOKEN.REQUIRED')
: '',
apiKeySid: v$.value.apiKeySid?.$error
? t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SID.REQUIRED')
: '',
apiKeySecret: v$.value.apiKeySecret?.$error
? t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SECRET.REQUIRED')
: '',
twimlAppSid: v$.value.twimlAppSid?.$error
? t('INBOX_MGMT.ADD.VOICE.TWILIO.TWIML_APP_SID.REQUIRED')
: '',
}));
function getProviderConfig() {
const config = {
account_sid: state.accountSid,
auth_token: state.authToken,
api_key_sid: state.apiKeySid,
api_key_secret: state.apiKeySecret,
};
if (state.twimlAppSid) config.outgoing_application_sid = state.twimlAppSid;
return config;
}
async function createChannel() {
const isFormValid = await v$.value.$validate();
if (!isFormValid) return;
try {
const channel = await store.dispatch('inboxes/createVoiceChannel', {
name: `Voice (${state.phoneNumber})`,
voice: {
phone_number: state.phoneNumber,
provider: 'twilio',
provider_config: getProviderConfig(),
},
});
router.replace({
name: 'settings_inboxes_add_agents',
params: { page: 'new', inbox_id: channel.id },
});
} catch (error) {
useAlert(
error.response?.data?.message ||
t('INBOX_MGMT.ADD.VOICE.API.ERROR_MESSAGE')
);
}
}
</script>
<template>
<div
class="overflow-auto col-span-6 p-6 w-full h-full rounded-t-lg border border-b-0 border-n-weak bg-n-solid-1"
>
<PageHeader
:header-title="t('INBOX_MGMT.ADD.VOICE.TITLE')"
:header-content="t('INBOX_MGMT.ADD.VOICE.DESC')"
/>
<form
class="flex flex-col gap-4 flex-wrap mx-0"
@submit.prevent="createChannel"
>
<Input
v-model="state.phoneNumber"
:label="t('INBOX_MGMT.ADD.VOICE.PHONE_NUMBER.LABEL')"
:placeholder="t('INBOX_MGMT.ADD.VOICE.PHONE_NUMBER.PLACEHOLDER')"
:message="formErrors.phoneNumber"
:message-type="formErrors.phoneNumber ? 'error' : 'info'"
@blur="v$.phoneNumber?.$touch"
/>
<Input
v-model="state.accountSid"
:label="t('INBOX_MGMT.ADD.VOICE.TWILIO.ACCOUNT_SID.LABEL')"
:placeholder="t('INBOX_MGMT.ADD.VOICE.TWILIO.ACCOUNT_SID.PLACEHOLDER')"
:message="formErrors.accountSid"
:message-type="formErrors.accountSid ? 'error' : 'info'"
@blur="v$.accountSid?.$touch"
/>
<Input
v-model="state.authToken"
type="password"
:label="t('INBOX_MGMT.ADD.VOICE.TWILIO.AUTH_TOKEN.LABEL')"
:placeholder="t('INBOX_MGMT.ADD.VOICE.TWILIO.AUTH_TOKEN.PLACEHOLDER')"
:message="formErrors.authToken"
:message-type="formErrors.authToken ? 'error' : 'info'"
@blur="v$.authToken?.$touch"
/>
<Input
v-model="state.apiKeySid"
:label="t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SID.LABEL')"
:placeholder="t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SID.PLACEHOLDER')"
:message="formErrors.apiKeySid"
:message-type="formErrors.apiKeySid ? 'error' : 'info'"
@blur="v$.apiKeySid?.$touch"
/>
<Input
v-model="state.apiKeySecret"
type="password"
:label="t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SECRET.LABEL')"
:placeholder="
t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SECRET.PLACEHOLDER')
"
:message="formErrors.apiKeySecret"
:message-type="formErrors.apiKeySecret ? 'error' : 'info'"
@blur="v$.apiKeySecret?.$touch"
/>
<Input
v-model="state.twimlAppSid"
:label="t('INBOX_MGMT.ADD.VOICE.TWILIO.TWIML_APP_SID.LABEL')"
:placeholder="
t('INBOX_MGMT.ADD.VOICE.TWILIO.TWIML_APP_SID.PLACEHOLDER')
"
:message="formErrors.twimlAppSid"
:message-type="formErrors.twimlAppSid ? 'error' : 'info'"
@blur="v$.twimlAppSid?.$touch"
/>
<div>
<NextButton
:is-loading="uiFlags.isCreating"
:disabled="isSubmitDisabled"
:label="t('INBOX_MGMT.ADD.VOICE.SUBMIT_BUTTON')"
type="submit"
/>
</div>
</form>
</div>
</template>
@@ -12,6 +12,7 @@ defineOptions({
provider="google"
:title="$t('INBOX_MGMT.ADD.GOOGLE.TITLE')"
: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')"
:error-message="$t('INBOX_MGMT.ADD.GOOGLE.ERROR_MESSAGE')"
/>
@@ -12,6 +12,7 @@ defineOptions({
provider="microsoft"
:title="$t('INBOX_MGMT.ADD.MICROSOFT.TITLE')"
: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')"
:error-message="$t('INBOX_MGMT.ADD.MICROSOFT.ERROR_MESSAGE')"
/>
@@ -30,9 +30,14 @@ const props = defineProps({
type: String,
required: true,
},
inputPlaceholder: {
type: String,
required: true,
},
});
const isRequestingAuthorization = ref(false);
const email = ref('');
const client = computed(() => {
if (props.provider === 'microsoft') {
@@ -45,7 +50,9 @@ const client = computed(() => {
async function requestAuthorization() {
try {
isRequestingAuthorization.value = true;
const response = await client.value.generateAuthorization();
const response = await client.value.generateAuthorization({
email: email.value,
});
const {
data: { url },
} = response;
@@ -68,6 +75,11 @@ async function requestAuthorization() {
:header-content="description"
/>
<form class="mt-6" @submit.prevent="requestAuthorization">
<woot-input
v-model="email"
type="email"
:placeholder="inputPlaceholder"
/>
<NextButton
:is-loading="isRequestingAuthorization"
type="submit"
@@ -29,7 +29,6 @@ const i18nMap = {
'Channel::Line': 'LINE',
'Channel::Api': 'API',
'Channel::Instagram': 'INSTAGRAM',
'Channel::Voice': 'VOICE',
};
const twilioChannelName = () => {
@@ -5,19 +5,15 @@ import {
useMapGetter,
useStore,
} from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import ButtonNext from 'next/button/Button.vue';
import notionClient from 'dashboard/api/notion_auth.js';
import Integration from './Integration.vue';
import Spinner from 'shared/components/Spinner.vue';
const { t } = useI18n();
const store = useStore();
const integrationLoaded = ref(false);
const integration = useFunctionGetter('integrations/getIntegration', 'notion');
const integration = useFunctionGetter('integrations/getIntegration', 'github');
const uiFlags = useMapGetter('integrations/getUIFlags');
@@ -25,32 +21,22 @@ const integrationAction = computed(() => {
if (integration.value.enabled) {
return 'disconnect';
}
return '';
return integration.value.action;
});
const authorize = async () => {
const response = await notionClient.generateAuthorization();
const {
data: { url },
} = response;
window.location.href = url;
};
const initializeNotionIntegration = async () => {
await store.dispatch('integrations/get', 'notion');
const initializeGithubIntegration = async () => {
await store.dispatch('integrations/get', 'github');
integrationLoaded.value = true;
};
onMounted(() => {
initializeNotionIntegration();
initializeGithubIntegration();
});
</script>
<template>
<div class="flex-grow flex-shrink p-4 overflow-auto mx-auto">
<div v-if="integrationLoaded && !uiFlags.isCreatingNotion">
<div class="flex-grow flex-shrink max-w-6xl p-4 mx-auto overflow-auto">
<div v-if="integrationLoaded && !uiFlags.isCreatingGithub">
<Integration
:integration-id="integration.id"
:integration-logo="integration.logo"
@@ -59,19 +45,10 @@ onMounted(() => {
:integration-enabled="integration.enabled"
:integration-action="integrationAction"
:delete-confirmation-text="{
title: t('INTEGRATION_SETTINGS.NOTION.DELETE.TITLE'),
message: t('INTEGRATION_SETTINGS.NOTION.DELETE.MESSAGE'),
title: $t('INTEGRATION_SETTINGS.GITHUB.DELETE.TITLE'),
message: $t('INTEGRATION_SETTINGS.GITHUB.DELETE.MESSAGE'),
}"
>
<template #action>
<ButtonNext
faded
blue
:label="t('INTEGRATION_SETTINGS.CONNECT.BUTTON_TEXT')"
@click="authorize"
/>
</template>
</Integration>
/>
</div>
<div v-else class="flex items-center justify-center flex-1">
<Spinner size="" color-scheme="primary" />
@@ -8,8 +8,8 @@ import DashboardApps from './DashboardApps/Index.vue';
import Slack from './Slack.vue';
import SettingsContent from '../Wrapper.vue';
import Linear from './Linear.vue';
import Notion from './Notion.vue';
import Shopify from './Shopify.vue';
import Github from './Github.vue';
export default {
routes: [
@@ -91,15 +91,6 @@ export default {
},
props: route => ({ code: route.query.code }),
},
{
path: 'notion',
name: 'settings_integrations_notion',
component: Notion,
meta: {
permissions: ['administrator'],
},
props: route => ({ code: route.query.code }),
},
{
path: 'shopify',
name: 'settings_integrations_shopify',
@@ -110,6 +101,14 @@ export default {
},
props: route => ({ error: route.query.error }),
},
{
path: 'github',
name: 'settings_integrations_github',
component: Github,
meta: {
permissions: ['administrator'],
},
},
{
path: ':integration_id',
name: 'settings_applications_integration',
@@ -75,34 +75,10 @@ export default {
onRegistrationSuccess() {
this.hasEnabledPushPermissions = true;
},
onRequestPermissions(value) {
if (value) {
// Enable / re-enable push notifications
requestPushPermissions({
onSuccess: this.onRegistrationSuccess,
});
} else {
// Disable push notifications
this.disablePushPermissions();
}
},
disablePushPermissions() {
verifyServiceWorkerExistence(registration =>
registration.pushManager
.getSubscription()
.then(subscription => {
if (subscription) {
return subscription.unsubscribe();
}
return null;
})
.finally(() => {
this.hasEnabledPushPermissions = false;
})
.catch(() => {
// error
})
);
onRequestPermissions() {
requestPushPermissions({
onSuccess: this.onRegistrationSuccess,
});
},
getPushSubscription() {
verifyServiceWorkerExistence(registration =>
@@ -59,7 +59,7 @@ const defaulSpanRender = cellProps =>
cellProps.getValue()
);
const columns = computed(() => [
const columns = [
columnHelper.accessor('name', {
header: t(`SUMMARY_REPORTS.${props.type.toUpperCase()}`),
width: 300,
@@ -90,7 +90,7 @@ const columns = computed(() => [
width: 200,
cell: defaulSpanRender,
}),
]);
];
const renderAvgTime = value => (value ? formatTime(value) : '--');
@@ -142,9 +142,7 @@ const table = useVueTable({
get data() {
return tableData.value;
},
get columns() {
return columns.value;
},
columns,
enableSorting: false,
getCoreRowModel: getCoreRowModel(),
});
@@ -9,7 +9,29 @@ import { throwErrorMessage } from '../utils/api';
import AnalyticsHelper from '../../helper/AnalyticsHelper';
import camelcaseKeys from 'camelcase-keys';
import { ACCOUNT_EVENTS } from '../../helper/AnalyticsHelper/events';
import { channelActions, buildInboxData } from './inboxes/channelActions';
const buildInboxData = inboxParams => {
const formData = new FormData();
const { channel = {}, ...inboxProperties } = inboxParams;
Object.keys(inboxProperties).forEach(key => {
formData.append(key, inboxProperties[key]);
});
const { selectedFeatureFlags, ...channelParams } = channel;
// selectedFeatureFlags needs to be empty when creating a website channel
if (selectedFeatureFlags) {
if (selectedFeatureFlags.length) {
selectedFeatureFlags.forEach(featureFlag => {
formData.append(`channel[selected_feature_flags][]`, featureFlag);
});
} else {
formData.append('channel[selected_feature_flags][]', '');
}
}
Object.keys(channelParams).forEach(key => {
formData.append(`channel[${key}]`, channel[key]);
});
return formData;
};
export const state = {
records: [],
@@ -198,12 +220,6 @@ export const actions = {
throw new Error(error);
}
},
...channelActions,
// TODO: Extract other create channel methods to separate files to reduce file size
// - createChannel
// - createWebsiteChannel
// - createTwilioChannel
// - createFBChannel
updateInbox: async ({ commit }, { id, formData = true, ...inboxParams }) => {
commit(types.default.SET_INBOXES_UI_FLAG, { isUpdating: true });
try {
@@ -1,52 +0,0 @@
import * as types from '../../mutation-types';
import InboxesAPI from '../../../api/inboxes';
import AnalyticsHelper from '../../../helper/AnalyticsHelper';
import { ACCOUNT_EVENTS } from '../../../helper/AnalyticsHelper/events';
export const buildInboxData = inboxParams => {
const formData = new FormData();
const { channel = {}, ...inboxProperties } = inboxParams;
Object.keys(inboxProperties).forEach(key => {
formData.append(key, inboxProperties[key]);
});
const { selectedFeatureFlags, ...channelParams } = channel;
// selectedFeatureFlags needs to be empty when creating a website channel
if (selectedFeatureFlags) {
if (selectedFeatureFlags.length) {
selectedFeatureFlags.forEach(featureFlag => {
formData.append(`channel[selected_feature_flags][]`, featureFlag);
});
} else {
formData.append('channel[selected_feature_flags][]', '');
}
}
Object.keys(channelParams).forEach(key => {
formData.append(`channel[${key}]`, channel[key]);
});
return formData;
};
const sendAnalyticsEvent = channelType => {
AnalyticsHelper.track(ACCOUNT_EVENTS.ADDED_AN_INBOX, {
channelType,
});
};
export const channelActions = {
createVoiceChannel: async ({ commit }, params) => {
try {
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: true });
const response = await InboxesAPI.create({
name: params.name,
channel: { ...params.voice, type: 'voice' },
});
commit(types.default.ADD_INBOXES, response.data);
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
sendAnalyticsEvent('voice');
return response.data;
} catch (error) {
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
throw error;
}
},
};
@@ -62,28 +62,6 @@ describe('#actions', () => {
});
});
describe('#createVoiceChannel', () => {
it('sends correct actions if API is success', async () => {
axios.post.mockResolvedValue({ data: inboxList[0] });
await actions.createVoiceChannel({ commit }, inboxList[0]);
expect(commit.mock.calls).toEqual([
[types.default.SET_INBOXES_UI_FLAG, { isCreating: true }],
[types.default.ADD_INBOXES, inboxList[0]],
[types.default.SET_INBOXES_UI_FLAG, { isCreating: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.post.mockRejectedValue({ message: 'Incorrect header' });
await expect(actions.createVoiceChannel({ commit })).rejects.toThrow(
Error
);
expect(commit.mock.calls).toEqual([
[types.default.SET_INBOXES_UI_FLAG, { isCreating: true }],
[types.default.SET_INBOXES_UI_FLAG, { isCreating: false }],
]);
});
});
describe('#createFBChannel', () => {
it('sends correct actions if API is success', async () => {
axios.post.mockResolvedValue({ data: inboxList[0] });
+4 -7
View File
@@ -112,14 +112,11 @@ export const InitializationHelpers = {
},
setDirectionAttribute: () => {
const htmlElement = document.querySelector('html');
// If direction is already applied through props, do not apply again (iframe case)
const hasDirApplied = htmlElement.getAttribute('data-dir-applied');
if (!htmlElement || hasDirApplied) return;
const portalElement = document.getElementById('portal');
if (!portalElement) return;
const localeFromHtml = htmlElement.lang;
htmlElement.dir =
localeFromHtml && getLanguageDirection(localeFromHtml) ? 'rtl' : 'ltr';
const locale = document.querySelector('.locale-switcher')?.value;
portalElement.dir = locale && getLanguageDirection(locale) ? 'rtl' : 'ltr';
},
initializeThemesInPortal: initializeTheme,
@@ -1,56 +1,64 @@
<script setup>
import { ref, useTemplateRef, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
<script>
import ArticleSkeletonLoader from 'shared/components/ArticleSkeletonLoader.vue';
const props = defineProps({
url: {
type: String,
default: '',
export default {
name: 'IframeLoader',
components: {
ArticleSkeletonLoader,
},
isRtl: {
type: Boolean,
default: false,
props: {
url: {
type: String,
default: '',
},
isRtl: {
type: Boolean,
default: false,
},
},
isDirApplied: {
type: Boolean,
default: false,
data() {
return {
isLoading: true,
showEmptyState: !this.url,
};
},
});
watch: {
isRtl: {
immediate: true,
handler(value) {
this.$nextTick(() => {
const iframeElement = this.$el.querySelector('iframe');
if (iframeElement) {
iframeElement.onload = () => {
try {
const iframeDocument =
iframeElement.contentDocument ||
(iframeElement.contentWindow &&
iframeElement.contentWindow.document);
const { t } = useI18n();
const iframe = useTemplateRef('iframe');
const isLoading = ref(true);
const showEmptyState = ref(!props.url);
const direction = computed(() => (props.isRtl ? 'rtl' : 'ltr'));
const applyDirection = () => {
if (!iframe.value) return;
if (!props.isDirApplied) return; // If direction is already applied through props, do not apply again (iframe case)
try {
const doc =
iframe.value.contentDocument || iframe.value.contentWindow?.document;
if (doc?.documentElement) {
doc.documentElement.dir = direction.value;
doc.documentElement.setAttribute('data-dir-applied', 'true');
}
} catch (e) {
// error
}
};
watch(() => props.isRtl, applyDirection);
const handleIframeLoad = () => {
isLoading.value = false;
applyDirection();
};
const handleIframeError = () => {
isLoading.value = false;
showEmptyState.value = true;
if (iframeDocument) {
iframeDocument.documentElement.dir = value ? 'rtl' : 'ltr';
}
} catch (e) {
// error
}
};
}
});
},
},
},
methods: {
handleIframeLoad() {
// Once loaded, the loading state is hidden
this.isLoading = false;
},
handleIframeError() {
// Hide the loading state and show the empty state when an error occurs
this.isLoading = false;
this.showEmptyState = true;
},
},
};
</script>
@@ -58,7 +66,6 @@ const handleIframeError = () => {
<div class="relative overflow-hidden pb-1/2 h-full">
<iframe
v-if="url"
ref="iframe"
:src="url"
class="absolute w-full h-full top-0 left-0"
@load="handleIframeLoad"
@@ -72,7 +79,7 @@ const handleIframeError = () => {
v-if="showEmptyState"
class="absolute w-full h-full top-0 left-0 flex justify-center items-center"
>
<p>{{ t('PORTAL.IFRAME_ERROR') }}</p>
<p>{{ $t('PORTAL.IFRAME_ERROR') }}</p>
</div>
</div>
</template>
@@ -1,40 +0,0 @@
/**
* Determine the best-matching locale from the list of locales allowed by the portal.
*
* The matching happens in the following order:
* 1. Exact match the visitor-selected locale equals one in the `allowedLocales` list
* (e.g., `fr` `fr`).
* 2. Base language match the base part of a compound locale (before the underscore)
* matches (e.g., `fr_CA` `fr`).
* 3. Variant match when the base language is selected but a regional variant exists
* in the portal list (e.g., `fr` `fr_BE`).
*
* If none of these rules find a match, the function returns `null`,
* Don't show popular articles if locale doesn't match with allowed locales
*
* @export
* @param {string} selectedLocale The locale selected by the visitor (e.g., `fr_CA`).
* @param {string[]} allowedLocales Array of locales enabled for the portal.
* @returns {(string|null)} A locale string that should be used, or `null` if no suitable match.
*/
export const getMatchingLocale = (selectedLocale = '', allowedLocales = []) => {
// Ensure inputs are valid
if (
!selectedLocale ||
!Array.isArray(allowedLocales) ||
!allowedLocales.length
) {
return null;
}
const [lang] = selectedLocale.split('_');
const priorityMatches = [
selectedLocale, // exact match
lang, // base language match
allowedLocales.find(l => l.startsWith(`${lang}_`)), // first variant match
];
// Return the first match that exists in the allowed list, or null
return priorityMatches.find(l => l && allowedLocales.includes(l)) ?? null;
};
@@ -1,28 +0,0 @@
import { getMatchingLocale } from 'shared/helpers/portalHelper';
describe('portalHelper - getMatchingLocale', () => {
it('returns exact match when present', () => {
const result = getMatchingLocale('fr', ['en', 'fr']);
expect(result).toBe('fr');
});
it('returns base language match when exact variant not present', () => {
const result = getMatchingLocale('fr_CA', ['en', 'fr']);
expect(result).toBe('fr');
});
it('returns variant match when base language not present', () => {
const result = getMatchingLocale('fr', ['en', 'fr_BE']);
expect(result).toBe('fr_BE');
});
it('returns null when no match found', () => {
const result = getMatchingLocale('de', ['en', 'fr']);
expect(result).toBeNull();
});
it('returns null for invalid inputs', () => {
expect(getMatchingLocale('', [])).toBeNull();
expect(getMatchingLocale(null, null)).toBeNull();
});
});
@@ -63,9 +63,6 @@ export default {
isATelegramChannel() {
return this.channelType === INBOX_TYPES.TELEGRAM;
},
isAVoiceChannel() {
return this.channelType === INBOX_TYPES.VOICE;
},
isATwilioSMSChannel() {
const { medium: medium = '' } = this.inbox;
return this.isATwilioChannel && medium === 'sms';
@@ -7,7 +7,6 @@ import { useRouter } from 'vue-router';
import { useStore } from 'dashboard/composables/store';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useDarkMode } from 'widget/composables/useDarkMode';
import { getMatchingLocale } from 'shared/helpers/portalHelper';
const store = useStore();
const router = useRouter();
@@ -21,8 +20,17 @@ const articleUiFlags = useMapGetter('article/uiFlags');
const locale = computed(() => {
const { locale: selectedLocale } = i18n;
const { allowed_locales: allowedLocales } = portal.value.config;
return getMatchingLocale(selectedLocale.value, allowedLocales);
const {
allowed_locales: allowedLocales,
default_locale: defaultLocale = 'en',
} = portal.value.config;
// IMPORTANT: Variation strict locale matching, Follow iso_639_1_code
// If the exact match of a locale is available in the list of portal locales, return it
// Else return the default locale. Eg: `es` will not work if `es_ES` is available in the list
if (allowedLocales.includes(selectedLocale)) {
return locale;
}
return defaultLocale;
});
const fetchArticles = () => {
@@ -38,7 +46,6 @@ const openArticleInArticleViewer = link => {
const params = new URLSearchParams({
show_plain_layout: 'true',
theme: prefersDarkMode.value ? 'dark' : 'light',
...(locale.value && { locale: locale.value }),
});
// Combine link with query parameters
@@ -57,8 +64,7 @@ const hasArticles = computed(
() =>
!articleUiFlags.value.isFetching &&
!articleUiFlags.value.isError &&
!!popularArticles.value.length &&
!!locale.value
!!popularArticles.value.length
);
onMounted(() => fetchArticles());
</script>
@@ -23,7 +23,6 @@ export const actions = {
commit('setError', false);
try {
if (!locale) return;
const cachedData = getFromCache(`${CACHE_KEY_PREFIX}${slug}_${locale}`);
if (cachedData) {
commit('setArticles', cachedData);
@@ -1,16 +1,24 @@
<script>
import IframeLoader from 'shared/components/IframeLoader.vue';
import { getLanguageDirection } from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
export default {
name: 'ArticleViewer',
components: {
IframeLoader,
},
computed: {
isRTL() {
return this.$root.$i18n.locale
? getLanguageDirection(this.$root.$i18n.locale)
: false;
},
},
};
</script>
<template>
<div class="bg-white h-full">
<IframeLoader :url="$route.query.link" />
<IframeLoader :url="$route.query.link" :is-rtl="isRTL" />
</div>
</template>
+1 -1
View File
@@ -35,7 +35,7 @@ class Webhooks::TelegramEventsJob < ApplicationJob
def process_event_params(channel, params)
return unless params[:telegram]
if params.dig(:telegram, :edited_message).present? || params.dig(:telegram, :edited_business_message).present?
if params.dig(:telegram, :edited_message).present?
Telegram::UpdateMessageService.new(inbox: channel.inbox, params: params['telegram'].with_indifferent_access).perform
else
Telegram::IncomingMessageService.new(inbox: channel.inbox, params: params['telegram'].with_indifferent_access).perform
@@ -47,10 +47,6 @@ class ReportingEventListener < BaseListener
message = extract_message_and_account(event)[0]
conversation = message.conversation
waiting_since = event.data[:waiting_since]
return if waiting_since.blank?
# When waiting_since is nil, set reply_time to 0
reply_time = message.created_at.to_i - waiting_since.to_i
reporting_event = ReportingEvent.new(
+3 -16
View File
@@ -69,10 +69,6 @@ class Channel::Telegram < ApplicationRecord
message.conversation[:additional_attributes]['chat_id']
end
def business_connection_id(message)
message.conversation[:additional_attributes]['business_connection_id']
end
def reply_to_message_id(message)
message.content_attributes['in_reply_to_external_id']
end
@@ -99,13 +95,7 @@ class Channel::Telegram < ApplicationRecord
end
def send_message(message)
response = message_request(
chat_id(message),
message.outgoing_content,
reply_markup(message),
reply_to_message_id(message),
business_connection_id: business_connection_id(message)
)
response = message_request(chat_id(message), message.outgoing_content, reply_markup(message), reply_to_message_id(message))
process_error(message, response)
response.parsed_response['result']['message_id'] if response.success?
end
@@ -141,12 +131,9 @@ class Channel::Telegram < ApplicationRecord
stripped_html.gsub('&lt;br&gt;', "\n")
end
def message_request(chat_id, text, reply_markup = nil, reply_to_message_id = nil, business_connection_id: nil)
def message_request(chat_id, text, reply_markup = nil, reply_to_message_id = nil)
text_payload = convert_markdown_to_telegram_html(text)
business_body = {}
business_body[:business_connection_id] = business_connection_id if business_connection_id
HTTParty.post("#{telegram_api_url}/sendMessage",
body: {
chat_id: chat_id,
@@ -154,6 +141,6 @@ class Channel::Telegram < ApplicationRecord
reply_markup: reply_markup,
parse_mode: 'HTML',
reply_to_message_id: reply_to_message_id
}.merge(business_body))
})
end
end
-10
View File
@@ -198,21 +198,11 @@ class Conversation < ApplicationRecord
private
def execute_after_update_commit_callbacks
handle_resolved_status_change
notify_status_change
create_activity
notify_conversation_updation
end
def handle_resolved_status_change
# When conversation is resolved, clear waiting_since using update_column to avoid callbacks
return unless saved_change_to_status? && status == 'resolved'
# rubocop:disable Rails/SkipsModelValidations
update_column(:waiting_since, nil)
# rubocop:enable Rails/SkipsModelValidations
end
def ensure_snooze_until_reset
self.snoozed_until = nil unless snoozed?
end
+19 -13
View File
@@ -43,6 +43,8 @@ class Integrations::App
"#{params[:action]}&client_id=#{client_id}&redirect_uri=#{self.class.slack_integration_url}"
when 'linear'
build_linear_action
when 'github'
build_github_action
else
params[:action]
end
@@ -55,11 +57,11 @@ class Integrations::App
when 'linear'
GlobalConfigService.load('LINEAR_CLIENT_ID', nil).present?
when 'shopify'
shopify_enabled?(account)
account.feature_enabled?('shopify_integration') && GlobalConfigService.load('SHOPIFY_CLIENT_ID', nil).present?
when 'leadsquared'
account.feature_enabled?('crm_integration')
when 'notion'
notion_enabled?(account)
when 'github'
GlobalConfigService.load('GITHUB_CLIENT_ID', nil).present?
else
true
end
@@ -77,6 +79,16 @@ class Integrations::App
].join('&')
end
def build_github_action
client_id = GlobalConfigService.load('GITHUB_CLIENT_ID', nil)
[
'https://github.com/login/oauth/authorize?response_type=code',
"client_id=#{client_id}",
"redirect_uri=#{self.class.github_integration_url}",
'scope=repo'
].join('&')
end
def enabled?(account)
case params[:id]
when 'webhook'
@@ -100,6 +112,10 @@ class Integrations::App
"#{ENV.fetch('FRONTEND_URL', nil)}/linear/callback"
end
def self.github_integration_url
"#{ENV.fetch('FRONTEND_URL', nil)}/github/callback"
end
class << self
def apps
Hashie::Mash.new(APPS_CONFIG)
@@ -115,14 +131,4 @@ class Integrations::App
all.detect { |app| app.id == params[:id] }
end
end
private
def shopify_enabled?(account)
account.feature_enabled?('shopify_integration') && GlobalConfigService.load('SHOPIFY_CLIENT_ID', nil).present?
end
def notion_enabled?(account)
account.feature_enabled?('notion_integration') && GlobalConfigService.load('NOTION_CLIENT_ID', nil).present?
end
end
-4
View File
@@ -53,10 +53,6 @@ class Integrations::Hook < ApplicationRecord
app_id == 'dialogflow'
end
def notion?
app_id == 'notion'
end
def disable
update(status: 'disabled')
end
@@ -24,15 +24,6 @@ class Instagram::Messenger::MessageText < Instagram::BaseMessageText
end
def handle_client_error(error)
# Handle error code 230: User consent is required to access user profile
# This typically occurs when the connected Instagram account attempts to send a message to a user
# who has never messaged this Instagram account before.
# We can safely ignore this error as per Facebook documentation.
if error.message.include?('230')
Rails.logger.warn error
return
end
Rails.logger.warn("[FacebookUserFetchClientError]: account_id #{@inbox.account_id} inbox_id #{@inbox.id}")
Rails.logger.warn("[FacebookUserFetchClientError]: #{error.message}")
ChatwootExceptionTracker.new(error, account: @inbox.account).capture_exception
@@ -1,41 +0,0 @@
class Linear::ActivityMessageService
attr_reader :conversation, :action_type, :issue_data, :user
def initialize(conversation:, action_type:, user:, issue_data: {})
@conversation = conversation
@action_type = action_type
@issue_data = issue_data
@user = user
end
def perform
return unless conversation && issue_data[:id] && user
content = generate_activity_content
return unless content
::Conversations::ActivityMessageJob.perform_later(conversation, activity_message_params(content))
end
private
def generate_activity_content
case action_type.to_sym
when :issue_created
I18n.t('conversations.activity.linear.issue_created', user_name: user.name, issue_id: issue_data[:id])
when :issue_linked
I18n.t('conversations.activity.linear.issue_linked', user_name: user.name, issue_id: issue_data[:id])
when :issue_unlinked
I18n.t('conversations.activity.linear.issue_unlinked', user_name: user.name, issue_id: issue_data[:id])
end
end
def activity_message_params(content)
{
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: :activity,
content: content
}
end
end
@@ -5,34 +5,22 @@ class LlmFormatter::ConversationLlmFormatter < LlmFormatter::DefaultLlmFormatter
sections << "Channel: #{@record.inbox.channel.name}"
sections << 'Message History:'
sections << if @record.messages.any?
build_messages(config)
build_messages
else
'No messages in this conversation'
end
sections << "Contact Details: #{@record.contact.to_llm_text}" if config[:include_contact_details]
attributes = build_attributes
if attributes.present?
sections << 'Conversation Attributes:'
sections << attributes
end
sections.join("\n")
end
private
def build_messages(config = {})
def build_messages
return "No messages in this conversation\n" if @record.messages.empty?
message_text = ''
messages = @record.messages.where.not(message_type: :activity).order(created_at: :asc)
messages.each do |message|
# Skip private messages unless explicitly included in config
next if message.private? && !config[:include_private_messages]
@record.messages.chat.order(created_at: :asc).each do |message|
message_text << format_message(message)
end
message_text
@@ -40,14 +28,6 @@ class LlmFormatter::ConversationLlmFormatter < LlmFormatter::DefaultLlmFormatter
def format_message(message)
sender = message.message_type == 'incoming' ? 'User' : 'Support agent'
sender = "[Private Note] #{sender}" if message.private?
"#{sender}: #{message.content}\n"
end
def build_attributes
attributes = @record.account.custom_attribute_definitions.with_attribute_model('conversation_attribute').map do |attribute|
"#{attribute.attribute_display_name}: #{@record.custom_attributes[attribute.attribute_key]}"
end
attributes.join("\n")
end
end
@@ -4,10 +4,8 @@ class MessageTemplates::Template::AutoResolve
def perform
return if conversation.account.auto_resolve_message.blank?
if within_messaging_window?
ActiveRecord::Base.transaction do
conversation.messages.create!(auto_resolve_message_params)
else
create_auto_resolve_not_sent_activity_message
end
end
@@ -16,21 +14,6 @@ class MessageTemplates::Template::AutoResolve
delegate :contact, :account, to: :conversation
delegate :inbox, to: :message
def within_messaging_window?
conversation.can_reply?
end
def create_auto_resolve_not_sent_activity_message
content = I18n.t('conversations.activity.auto_resolve.not_sent_due_to_messaging_window')
activity_message_params = {
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: :activity,
content: content
}
::Conversations::ActivityMessageJob.perform_later(conversation, activity_message_params) if content
end
def auto_resolve_message_params
{
account_id: @conversation.account_id,
@@ -8,25 +8,17 @@ class Telegram::IncomingMessageService
def perform
# chatwoot doesn't support group conversations at the moment
transform_business_message!
return unless private_message?
set_contact
update_contact_avatar
set_conversation
# TODO: Since the recent Telegram Business update, we need to explicitly mark messages as read using an additional request.
# Otherwise, the client will see their messages as unread.
# Chatwoot defines a 'read' status in its enum but does not currently update this status for Telegram conversations.
# We have two options:
# 1. Send the read request to Telegram here, immediately when the message is created.
# 2. Properly update the read status in the Chatwoot UI and trigger the Telegram request when the agent actually reads the message.
# See: https://core.telegram.org/bots/api#readbusinessmessage
@message = @conversation.messages.build(
content: telegram_params_message_content,
account_id: @inbox.account_id,
inbox_id: @inbox.id,
message_type: message_type,
sender: message_sender,
message_type: :incoming,
sender: @contact,
content_attributes: telegram_params_content_attributes,
source_id: telegram_params_message_id.to_s
)
@@ -44,11 +36,6 @@ class Telegram::IncomingMessageService
contact_attributes: contact_attributes
).perform
# TODO: Should we update contact_attributes when the user changes their first or last name?
# In business chats, when our Telegram bot initiates the conversation,
# the message does not include a language code.
# This is critical for AI assistants and translation plugins.
@contact_inbox = contact_inbox
@contact = contact_inbox.contact
end
@@ -102,19 +89,10 @@ class Telegram::IncomingMessageService
def conversation_additional_attributes
{
chat_id: telegram_params_chat_id,
business_connection_id: telegram_params_business_connection_id
chat_id: telegram_params_chat_id
}
end
def message_type
business_message_outgoing? ? :outgoing : :incoming
end
def message_sender
business_message_outgoing? ? nil : @contact
end
def file_content_type
return :image if image_message?
return :audio if audio_message?
@@ -213,8 +191,4 @@ class Telegram::IncomingMessageService
params[:message][:video].presence ||
params[:message][:video_note].presence
end
def transform_business_message!
params[:message] = params[:business_message] if params[:business_message] && !params[:message]
end
end
+4 -33
View File
@@ -13,17 +13,6 @@ module Telegram::ParamHelpers
{}
end
def business_message?
telegram_params_business_connection_id.present?
end
# In business bot mode we will receive messages from our telegram.
# This is our messages posted via telegram client.
# Such messages should be outgoing (from us to client)
def business_message_outgoing?
business_message? && telegram_params_base_object[:chat][:id] != telegram_params_base_object[:from][:id]
end
def message_params?
params[:message].present?
end
@@ -40,34 +29,24 @@ module Telegram::ParamHelpers
end
end
def contact_params
if business_message_outgoing?
telegram_params_base_object[:chat]
else
telegram_params_base_object[:from]
end
end
def telegram_params_from_id
return telegram_params_base_object[:chat][:id] if business_message?
telegram_params_base_object[:from][:id]
end
def telegram_params_first_name
contact_params[:first_name]
telegram_params_base_object[:from][:first_name]
end
def telegram_params_last_name
contact_params[:last_name]
telegram_params_base_object[:from][:last_name]
end
def telegram_params_username
contact_params[:username]
telegram_params_base_object[:from][:username]
end
def telegram_params_language_code
contact_params[:language_code]
telegram_params_base_object[:from][:language_code]
end
def telegram_params_chat_id
@@ -78,14 +57,6 @@ module Telegram::ParamHelpers
end
end
def telegram_params_business_connection_id
if callback_query_params?
params[:callback_query][:message][:business_connection_id]
else
telegram_params_base_object[:business_connection_id]
end
end
def telegram_params_message_content
if callback_query_params?
params[:callback_query][:data]
@@ -71,7 +71,6 @@ class Telegram::SendAttachmentsService
HTTParty.post("#{channel.telegram_api_url}/sendMediaGroup",
body: {
chat_id: chat_id,
**business_connection_body,
media: attachments.map { |hash| hash.except(:attachment) }.to_json,
reply_to_message_id: reply_to_message_id
})
@@ -109,7 +108,6 @@ class Telegram::SendAttachmentsService
HTTParty.post("#{channel.telegram_api_url}/sendDocument",
body: {
chat_id: chat_id,
**business_connection_body,
document: file,
reply_to_message_id: reply_to_message_id
},
@@ -137,14 +135,4 @@ class Telegram::SendAttachmentsService
def channel
@channel ||= message.inbox.channel
end
def business_connection_id
@business_connection_id ||= channel.business_connection_id(message)
end
def business_connection_body
body = {}
body[:business_connection_id] = business_connection_id if business_connection_id
body
end
end
@@ -5,7 +5,6 @@ class Telegram::UpdateMessageService
pattr_initialize [:inbox!, :params!]
def perform
transform_business_message!
find_contact_inbox
find_conversation
find_message
@@ -37,8 +36,4 @@ class Telegram::UpdateMessageService
@message.update!(content: edited_message[:caption])
end
end
def transform_business_message!
params[:edited_message] = params[:edited_business_message] if params[:edited_business_message].present?
end
end
@@ -4,9 +4,7 @@
I18n.t('reports.label_csv.label_title'),
I18n.t('reports.label_csv.conversations_count'),
I18n.t('reports.label_csv.avg_first_response_time'),
I18n.t('reports.label_csv.avg_resolution_time'),
I18n.t('reports.label_csv.avg_reply_time'),
I18n.t('reports.label_csv.resolution_count'),
I18n.t('reports.label_csv.avg_resolution_time')
]
%>
<%= CSVSafe.generate_line headers -%>
@@ -3,7 +3,7 @@
%>
<%= javascript_include_tag "secretField" %>
<div data-secret-text="<%= field.data %>" class="cell-data__secret-field">
<span data-secret-masked="true"></span>
<span data-secret-masked="true">••••••••••</span>
<button onclick="toggleSecretField(event)" data-secret-toggler>
<svg width="20" height="20">
<use xlink:href="#eye-show" />
+1 -1
View File
@@ -4,7 +4,7 @@
<%= javascript_include_tag "secretField" %>
<div data-secret-text="<%= field.data %>" class="cell-data__secret-field">
<span data-secret-masked="true"></span>
<span data-secret-masked="true">••••••••••</span>
<button onclick="toggleSecretField(event)" data-secret-toggler>
<svg width="20" height="20">
<use xlink:href="#eye-show" />
@@ -6,8 +6,8 @@
<strong>Chatwoot Installation:</strong> {{ meta.instance_url }}<br>
<strong>Account ID:</strong> {{ meta.account_id }}<br>
<strong>Account Name:</strong> {{ meta.account_name }}<br>
<strong>Deletion due at:</strong> {{ meta.marked_for_deletion_at }}<br>
<strong>Deleted At:</strong> {{ meta.deleted_at }}<br>
<strong>Marked for Deletion at:</strong> {{ meta.marked_for_deletion_at }}<br>
<strong>Deletion Reason:</strong> {{ meta.deletion_reason }}
</p>
@@ -1,5 +1,5 @@
<% if @message.content %>
<%= ChatwootMarkdownRenderer.new(@message.outgoing_content).render_message %>
<%= ChatwootMarkdownRenderer.new(@message.content).render_message %>
<% end %>
<% if @large_attachments.present? %>
<p>Attachments:</p>
@@ -1,18 +1,18 @@
<header class="sticky top-0 z-50 w-full bg-white shadow-sm dark:bg-slate-900">
<nav class="hidden sm:flex max-w-5xl px-4 mx-auto md:px-8" aria-label="Top">
<div class="flex items-center w-full py-5 overflow-hidden">
<a href="<%= generate_home_link(@portal.slug, @portal.config['default_locale'] || params[:locale], @theme_from_params, @is_plain_layout_enabled) %>" class="flex items-center min-w-0 h-10">
<a href="<%= generate_home_link(@portal.slug, @portal.config['default_locale'] || params[:locale], @theme_from_params, @is_plain_layout_enabled) %>" class="flex items-center h-10">
<% if @portal.logo.present? %>
<img src="<%= url_for(@portal.logo) %>" class="w-auto h-10 ltr:mr-2 rtl:ml-2" draggable="false" />
<img src="<%= url_for(@portal.logo) %>" class="w-auto h-10 ltr:mr-2 rtl:ml-2" />
<% end %>
<span class="text-lg font-semibold text-slate-900 truncate dark:text-white <%= @portal.logo.present? ? 'hidden lg:block' : 'hidden sm:block' %>"><%= @portal.name %></span>
<span class="text-lg font-semibold text-slate-900 dark:text-white <%= @portal.logo.present? ? 'hidden' : 'hidden sm:block' %>"><%= @portal.name %></span>
</a>
</div>
<%# Go to homepage link section %>
<div class="flex items-center justify-between gap-2 sm:gap-5">
<% if @portal.homepage_link %>
<div class="px-1 py-2 ltr:ml-8 rtl:mr-8 cursor-pointer block">
<div class="hidden px-1 py-2 ltr:ml-8 rtl:mr-8 cursor-pointer md:block">
<div class="flex-grow flex-shrink-0">
<a id="header-action-button" target="_blank" rel="noopener noreferrer nofollow" href="<%= @portal.homepage_link %>" class="flex flex-row items-center gap-1 text-sm font-medium whitespace-nowrap text-slate-800 dark:text-slate-100 stroke-slate-700 dark:stroke-slate-200">
<%= render partial: 'icons/redirect' %>
@@ -99,11 +99,11 @@
<nav class="flex sm:hidden max-w-5xl px-4 mx-auto" aria-label="Mobile Top">
<div class="flex items-center justify-between w-full py-5">
<a href="<%= generate_home_link(@portal.slug, @portal.config['default_locale'] || params[:locale], @theme_from_params, @is_plain_layout_enabled) %>" class="flex items-center min-w-0 h-10 text-lg font-semibold text-slate-900 dark:text-white">
<a href="<%= generate_home_link(@portal.slug, @portal.config['default_locale'] || params[:locale], @theme_from_params, @is_plain_layout_enabled) %>" class="flex items-center h-10 text-lg font-semibold text-slate-900 dark:text-white">
<% if @portal.logo.present? %>
<img src="<%= url_for(@portal.logo) %>" class="w-auto h-10 ltr:mr-2 rtl:ml-2" draggable="false" />
<img src="<%= url_for(@portal.logo) %>" class="w-auto h-10 ltr:mr-2 rtl:ml-2" />
<% end %>
<span class="text-lg font-semibold text-slate-900 truncate dark:text-white <%= @portal.logo.present? ? 'hidden' : 'sm:hidden block' %>"><%= @portal.name %></span>
<span class="text-lg font-semibold text-slate-900 dark:text-white <%= @portal.logo.present? ? 'hidden' : 'sm:hidden block' %>"><%= @portal.name %></span>
</a>
<!-- Mobile Menu Component -->
@@ -2,7 +2,7 @@
<section id="portal-bg" class="w-full bg-white dark:bg-slate-900 shadow-inner">
<div id="portal-bg-gradient" class="pt-8 pb-8 md:pt-14 md:pb-6 min-h-[240px] md:min-h-[260px]">
<div class="mx-auto max-w-5xl px-4 md:px-8 flex flex-col items-start">
<span class="text-sm leading-[24px] font-semibold text-slate-600 dark:text-slate-300 mb-1 <%= @portal.logo.present? ? 'block lg:hidden' : 'hidden' %>"><%= @portal.name %></span>
<span class="text-sm leading-[24px] font-semibold text-slate-600 dark:text-slate-300 mb-1 <%= @portal.logo.present? ? 'block' : 'hidden' %>"><%= @portal.name %></span>
<h1 class="text-2xl md:text-4xl text-slate-900 dark:text-white font-semibold leading-normal">
<%= portal.header_text %>
</h1>
@@ -1,4 +1,3 @@
<% has_multiple_locales = @portal.config["allowed_locales"].length > 1 %>
<input type="checkbox" id="mobile-menu-toggle" class="peer/menu hidden" />
@@ -47,12 +46,10 @@
</div>
</div>
<% if has_multiple_locales %>
<span class="h-px bg-slate-100/70 dark:bg-slate-800/70 w-full"></span>
<% end %>
<span class="h-px bg-slate-100/70 dark:bg-slate-800/70 w-full"></span>
<!-- Locale Switcher -->
<% if has_multiple_locales %>
<% if @portal.config["allowed_locales"].length > 1 %>
<div id="header-action-button" class="flex flex-col gap-2">
<h3 class="text-base font-medium text-slate-700 dark:text-slate-300 my-2">
<%= I18n.t('public_portal.header.language', default: 'Language') %>
@@ -156,14 +156,12 @@
<path d="M 8 3 C 5.243 3 3 5.243 3 8 L 3 16 C 3 18.757 5.243 21 8 21 L 16 21 C 18.757 21 21 18.757 21 16 L 21 8 C 21 5.243 18.757 3 16 3 L 8 3 z M 8 5 L 16 5 C 17.654 5 19 6.346 19 8 L 19 16 C 19 17.654 17.654 19 16 19 L 8 19 C 6.346 19 5 17.654 5 16 L 5 8 C 5 6.346 6.346 5 8 5 z M 17 6 A 1 1 0 0 0 16 7 A 1 1 0 0 0 17 8 A 1 1 0 0 0 18 7 A 1 1 0 0 0 17 6 z M 12 7 C 9.243 7 7 9.243 7 12 C 7 14.757 9.243 17 12 17 C 14.757 17 17 14.757 17 12 C 17 9.243 14.757 7 12 7 z M 12 9 C 13.654 9 15 10.346 15 12 C 15 13.654 13.654 15 12 15 C 10.346 15 9 13.654 9 12 C 9 10.346 10.346 9 12 9 z"/>
</symbol>
<symbol id="icon-notion" viewBox="0 0 24 24">
<path fill="currentColor" d="M6.104 5.91c.584.474.802.438 1.898.365l10.332-.62c.22 0 .037-.22-.036-.256l-1.716-1.24c-.329-.255-.767-.548-1.606-.475l-10.005.73c-.364.036-.437.219-.292.365zm.62 2.408v10.87c0 .585.292.803.95.767l11.354-.657c.657-.036.73-.438.73-.913V7.588c0-.474-.182-.73-.584-.693l-11.866.693c-.438.036-.584.255-.584.73m11.21.583c.072.328 0 .657-.33.694l-.547.109v8.025c-.475.256-.913.401-1.278.401c-.584 0-.73-.182-1.168-.729l-3.579-5.618v5.436l1.133.255s0 .656-.914.656l-2.519.146c-.073-.146 0-.51.256-.583l.657-.182v-7.187l-.913-.073c-.073-.329.11-.803.621-.84l2.702-.182l3.724 5.692V9.886l-.95-.109c-.072-.402.22-.693.585-.73zM4.131 3.429l10.406-.766c1.277-.11 1.606-.036 2.41.547l3.321 2.335c.548.401.731.51.731.948v12.805c0 .803-.292 1.277-1.314 1.35l-12.085.73c-.767.036-1.132-.073-1.534-.584L3.62 17.62c-.438-.584-.62-1.021-.62-1.533V4.705c0-.656.292-1.203 1.132-1.276"/>
</symbol>
<symbol id="icon-shopify" viewBox="0 0 32 32">
<path fill="currentColor" d="m20.448 31.974l9.625-2.083s-3.474-23.484-3.5-23.641s-.156-.255-.281-.255c-.13 0-2.573-.182-2.573-.182s-1.703-1.698-1.922-1.88a.4.4 0 0 0-.161-.099l-1.219 28.141zm-4.833-16.901s-1.083-.563-2.365-.563c-1.932 0-2.005 1.203-2.005 1.521c0 1.641 4.318 2.286 4.318 6.172c0 3.057-1.922 5.01-4.542 5.01c-3.141 0-4.719-1.953-4.719-1.953l.859-2.781s1.661 1.422 3.042 1.422c.901 0 1.302-.724 1.302-1.245c0-2.156-3.542-2.255-3.542-5.807c-.047-2.984 2.094-5.891 6.438-5.891c1.677 0 2.5.479 2.5.479l-1.26 3.625zm-.719-13.969c.177 0 .359.052.536.182c-1.313.62-2.75 2.188-3.344 5.323a76 76 0 0 1-2.516.771c.688-2.38 2.359-6.26 5.323-6.26zm1.646 3.932v.182c-1.005.307-2.115.646-3.193.979c.62-2.37 1.776-3.526 2.781-3.958c.255.667.411 1.568.411 2.797zm.718-2.973c.922.094 1.521 1.151 1.901 2.339c-.464.151-.979.307-1.542.484v-.333c0-1.005-.13-1.828-.359-2.495zm3.99 1.718c-.031 0-.083.026-.104.026c-.026 0-.385.099-.953.281C19.63 2.442 18.625.927 16.849.927h-.156C16.183.281 15.558 0 15.021 0c-4.141 0-6.12 5.172-6.74 7.797c-1.594.484-2.75.844-2.88.896c-.901.286-.927.313-1.031 1.161c-.099.615-2.438 18.75-2.438 18.75L20.01 32z"/>
</symbol>
<symbol id="icon-github" viewBox="0 0 24 24">
<path fill="currentColor" d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</symbol>
<symbol id="icon-slack" viewBox="0 0 24 24">
<path fill="currentColor" d="M6.527 14.514A1.973 1.973 0 0 1 4.56 16.48a1.973 1.973 0 0 1-1.968-1.967c0-1.083.885-1.968 1.968-1.968h1.967zm.992 0c0-1.083.885-1.968 1.968-1.968s1.967.885 1.967 1.968v4.927a1.973 1.973 0 0 1-1.967 1.968a1.973 1.973 0 0 1-1.968-1.968zm1.968-7.987A1.973 1.973 0 0 1 7.519 4.56c0-1.083.885-1.967 1.968-1.967s1.967.884 1.967 1.967v1.968zm0 .992c1.083 0 1.967.884 1.967 1.967a1.973 1.973 0 0 1-1.967 1.968H4.56a1.973 1.973 0 0 1-1.968-1.968c0-1.083.885-1.967 1.968-1.967zm7.986 1.967c0-1.083.885-1.967 1.968-1.967s1.968.884 1.968 1.967a1.973 1.973 0 0 1-1.968 1.968h-1.968zm-.991 0a1.973 1.973 0 0 1-1.968 1.968a1.973 1.973 0 0 1-1.968-1.968V4.56c0-1.083.885-1.967 1.968-1.967s1.968.884 1.968 1.967zm-1.968 7.987c1.083 0 1.968.885 1.968 1.968a1.973 1.973 0 0 1-1.968 1.968a1.973 1.973 0 0 1-1.968-1.968v-1.968zm0-.992a1.973 1.973 0 0 1-1.968-1.967c0-1.083.885-1.968 1.968-1.968h4.927c1.083 0 1.968.885 1.968 1.968a1.973 1.973 0 0 1-1.968 1.967z"/>
</symbol>

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

+20 -29
View File
@@ -22,36 +22,27 @@
<% end %>
<div class="bg-white py-2 px-4 xl:px-0">
<%= javascript_include_tag "secretField" %>
<div class="flex items-center mb-6">
<h4 class="text-sm font-medium leading-5 h-5 text-n-slate-12 mr-4">Installation Identifier</h4>
<div data-secret-text="<%= ChatwootHub.installation_identifier %>" class="cell-data__secret-field text-sm leading-5 h-5 text-n-slate-11">
<span data-secret-masked="true" class="select-none"></span>
<button type="button" onclick="toggleSecretField(event)" data-secret-toggler class="inline-flex items-center justify-center h-5 w-5 p-0 rounded text-n-slate-11 hover:text-n-slate-12 transition-colors focus:outline-none">
<svg width="20" height="20" class="w-5 h-5 fill-current">
<use xlink:href="#eye-show" />
</svg>
</button>
<button type="button" onclick="copySecretField(event)" data-secret-copier class="inline-flex items-center justify-center h-5 w-5 p-0 rounded text-n-slate-11 hover:text-n-slate-12 transition-colors focus:outline-none">
<svg width="20" height="20" class="w-5 h-5 fill-current">
<use xlink:href="#icon-copy" />
</svg>
</button>
<div class="mb-4">
<div class="flex items-center gap-2">
<h2 class="h-5 leading-5 text-n-slate-12 font-medium">Current plan</h2>
<a href="<%= refresh_super_admin_settings_url %>" class="inline-flex gap-1 text-xs font-medium items-center text-woot-500 hover:text-woot-700">
<svg width="16" height="16"><use xlink:href="#icon-refresh-line" /></svg>
<span>Refresh</span>
</a>
</div>
<p class="text-n-slate-11 mt-1"><%= SuperAdmin::FeaturesHelper.plan_details.html_safe %></p>
<div class="flex items-center mt-6">
<h4 class="text-sm font-medium leading-5 h-5 text-n-slate-12 mr-4">Installation Identifier</h4>
<span class="text-sm leading-5 h-5 text-n-slate-11"><%= ChatwootHub.installation_identifier %></span>
</div>
</div>
<div class="flex p-4 outline outline-1 outline-n-container rounded-lg items-start md:items-center shadow-sm flex-col md:flex-row">
<div class="flex p-4 outline outline-1 outline-n-container rounded-lg mt-8 items-start md:items-center shadow-sm flex-col md:flex-row">
<div class="flex flex-col flex-grow gap-1">
<div class="flex items-center gap-2">
<h2 class="h-5 leading-5 text-n-slate-12 text-sm font-medium">Current plan</h2>
<a href="<%= refresh_super_admin_settings_url %>" class="inline-flex gap-1 text-xs font-medium items-center text-woot-500 hover:text-woot-700">
<svg width="16" height="16"><use xlink:href="#icon-refresh-line" /></svg>
<span>Refresh</span>
</a>
</div>
<h2 class="h-5 leading-5 text-n-slate-12 text-sm font-medium">Current plan</h2>
<p class="text-n-slate-11 m-0 text-sm"><%= SuperAdmin::FeaturesHelper.plan_details.html_safe %></p>
</div>
<a href="<%= ChatwootHub.billing_url %>" target="_blank" rel="noopener noreferrer">
<button class="mt-4 md:mt-0 flex gap-1 items-center bg-transparent shadow-sm h-9 hover:text-n-slate-12 hover:bg-slate-50 outline outline-1 outline-n-container rounded text-n-slate-11 font-medium p-2 focus:outline-none">
<button class="mt-4 md:mt-0 flex gap-1 items-center bg-transparent shadow-sm h-9 hover:text-n-slate-12 hover:bg-slate-50 outline outline-1 outline-n-container rounded text-n-slate-11 font-medium p-2">
<svg width="16" height="16"><use xlink:href="#icon-settings-2-line" /></svg>
<span class="px-1">Manage</span>
</button>
@@ -73,14 +64,14 @@
<p class="text-n-slate-11 m-0 text-sm">Do you face any issues? We are here to help.</p>
</div>
<a href="https://discord.gg/cJXdrwS" target="_blank">
<button class="flex mt-4 md:mt-0 gap-1 items-center bg-transparent shadow-sm h-9 bg-violet-500 hover:bg-violet-600 text-white border border-solid border-violet-600 rounded font-medium p-2 focus:outline-none">
<svg class="h-4 w-4" width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M13.209 3.103c-.495-1.004-1.926-1.004-2.421 0L8.43 7.88l-5.273.766c-1.107.161-1.55 1.522-.748 2.303l3.815 3.72-.9 5.25c-.19 1.103.968 1.944 1.959 1.424l4.715-2.48 4.716 2.48c.99.52 2.148-.32 1.96-1.424l-.902-5.25 3.816-3.72c.8-.78.359-2.142-.748-2.303l-5.273-.766-2.358-4.777ZM9.74 8.615l2.258-4.576 2.259 4.576a1.35 1.35 0 0 0 1.016.738l5.05.734-3.654 3.562a1.35 1.35 0 0 0-.388 1.195l.862 5.03-4.516-2.375a1.35 1.35 0 0 0-1.257 0l-4.516 2.374.862-5.029a1.35 1.35 0 0 0-.388-1.195l-3.654-3.562 5.05-.734c.44-.063.82-.34 1.016-.738ZM1.164 3.782a.75.75 0 0 0 .118 1.054l2.5 2a.75.75 0 1 0 .937-1.172l-2.5-2a.75.75 0 0 0-1.055.118Z" fill="currentColor"/><path d="M22.836 18.218a.75.75 0 0 0-.117-1.054l-2.5-2a.75.75 0 0 0-.938 1.172l2.5 2a.75.75 0 0 0 1.055-.117ZM1.282 17.164a.75.75 0 1 0 .937 1.172l2.5-2a.75.75 0 0 0-.937-1.172l-2.5 2ZM22.836 3.782a.75.75 0 0 1-.117 1.054l-2.5 2a.75.75 0 0 1-.938-1.172l2.5-2a.75.75 0 0 1 1.055.118Z" fill="currentColor"/></svg>
<button class="flex mt-4 md:mt-0 gap-1 items-center bg-transparent shadow-sm h-9 bg-violet-500 hover:bg-violet-600 text-white border border-solid border-violet-600 rounded font-medium p-2">
<svg class="h-4 w-4" width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10a9.96 9.96 0 0 1-4.587-1.112l-3.826 1.067a1.25 1.25 0 0 1-1.54-1.54l1.068-3.823A9.96 9.96 0 0 1 2 12C2 6.477 6.477 2 12 2Zm0 1.5A8.5 8.5 0 0 0 3.5 12c0 1.47.373 2.883 1.073 4.137l.15.27-1.112 3.984 3.987-1.112.27.15A8.5 8.5 0 1 0 12 3.5ZM8.75 13h4.498a.75.75 0 0 1 .102 1.493l-.102.007H8.75a.75.75 0 0 1-.102-1.493L8.75 13h4.498H8.75Zm0-3.5h6.505a.75.75 0 0 1 .101 1.493l-.101.007H8.75a.75.75 0 0 1-.102-1.493L8.75 9.5h6.505H8.75Z" fill="currentColor"/></svg>
<span class="px-1">Community Support</span>
</button>
</a>
<% if ChatwootHub.pricing_plan !='community' %>
<button class="ml-4 flex gap-1 items-center bg-transparent h-9 hover:text-n-slate-12 hover:bg-slate-50 border border-solid border-slate-100 rounded text-n-slate-11 font-medium p-2 focus:outline-none" onclick="window.$chatwoot.toggle('open')">
<svg class="h-4 w-4" width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M13.209 3.103c-.495-1.004-1.926-1.004-2.421 0L8.43 7.88l-5.273.766c-1.107.161-1.55 1.522-.748 2.303l3.815 3.72-.9 5.25c-.19 1.103.968 1.944 1.959 1.424l4.715-2.48 4.716 2.48c.99.52 2.148-.32 1.96-1.424l-.902-5.25 3.816-3.72c.8-.78.359-2.142-.748-2.303l-5.273-.766-2.358-4.777ZM9.74 8.615l2.258-4.576 2.259 4.576a1.35 1.35 0 0 0 1.016.738l5.05.734-3.654 3.562a1.35 1.35 0 0 0-.388 1.195l.862 5.03-4.516-2.375a1.35 1.35 0 0 0-1.257 0l-4.516 2.374.862-5.029a1.35 1.35 0 0 0-.388-1.195l-3.654-3.562 5.05-.734c.44-.063.82-.34 1.016-.738ZM1.164 3.782a.75.75 0 0 0 .118 1.054l2.5 2a.75.75 0 1 0 .937-1.172l-2.5-2a.75.75 0 0 0-1.055.118Z" fill="currentColor"/><path d="M22.836 18.218a.75.75 0 0 0-.117-1.054l-2.5-2a.75.75 0 0 0-.938 1.172l2.5 2a.75.75 0 0 0 1.055-.117ZM1.282 17.164a.75.75 0 1 0 .937 1.172l2.5-2a.75.75 0 0 0-.937-1.172l-2.5 2ZM22.836 3.782a.75.75 0 0 1-.117 1.054l-2.5 2a.75.75 0 0 1-.938-1.172l2.5-2a.75.75 0 0 1 1.055.118Z" fill="currentColor"/></svg>
<button class="ml-4 flex gap-1 items-center bg-transparent h-9 hover:text-n-slate-12 hover:bg-slate-50 border border-solid border-slate-100 rounded text-n-slate-11 font-medium p-2" onclick="window.$chatwoot.toggle('open')">
<svg class="h-4 w-4" width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10a9.96 9.96 0 0 1-4.587-1.112l-3.826 1.067a1.25 1.25 0 0 1-1.54-1.54l1.068-3.823A9.96 9.96 0 0 1 2 12C2 6.477 6.477 2 12 2Zm0 1.5A8.5 8.5 0 0 0 3.5 12c0 1.47.373 2.883 1.073 4.137l.15.27-1.112 3.984 3.987-1.112.27.15A8.5 8.5 0 1 0 12 3.5ZM8.75 13h4.498a.75.75 0 0 1 .102 1.493l-.102.007H8.75a.75.75 0 0 1-.102-1.493L8.75 13h4.498H8.75Zm0-3.5h6.505a.75.75 0 0 1 .101 1.493l-.101.007H8.75a.75.75 0 0 1-.102-1.493L8.75 9.5h6.505H8.75Z" fill="currentColor"/></svg>
<span class="px-1">Chat Support</span>
</button>
<% end %>
@@ -99,7 +90,7 @@
</div>
<% if !attrs[:enabled] %>
<div class="flex h-9 absolute top-5 items-center invisible group-hover:visible">
<a href="<%= ChatwootHub.billing_url %>" target="_blank" rel="noopener noreferrer" class="flex gap-1 items-center bg-slate-100 h-9 hover:bg-slate-300 hover:text-n-slate-12 border border-solid border-slate-100 rounded text-n-slate-11 font-medium p-2 focus:outline-none">
<a href="<%= ChatwootHub.billing_url %>" target="_blank" rel="noopener noreferrer" class="flex gap-1 items-center bg-slate-100 h-9 hover:bg-slate-300 hover:text-n-slate-12 border border-solid border-slate-100 rounded text-n-slate-11 font-medium p-2">
<svg class="h-4 w-4" width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M13.209 3.103c-.495-1.004-1.926-1.004-2.421 0L8.43 7.88l-5.273.766c-1.107.161-1.55 1.522-.748 2.303l3.815 3.72-.9 5.25c-.19 1.103.968 1.944 1.959 1.424l4.715-2.48 4.716 2.48c.99.52 2.148-.32 1.96-1.424l-.902-5.25 3.816-3.72c.8-.78.359-2.142-.748-2.303l-5.273-.766-2.358-4.777ZM9.74 8.615l2.258-4.576 2.259 4.576a1.35 1.35 0 0 0 1.016.738l5.05.734-3.654 3.562a1.35 1.35 0 0 0-.388 1.195l.862 5.03-4.516-2.375a1.35 1.35 0 0 0-1.257 0l-4.516 2.374.862-5.029a1.35 1.35 0 0 0-.388-1.195l-3.654-3.562 5.05-.734c.44-.063.82-.34 1.016-.738ZM1.164 3.782a.75.75 0 0 0 .118 1.054l2.5 2a.75.75 0 1 0 .937-1.172l-2.5-2a.75.75 0 0 0-1.055.118Z" fill="currentColor"/><path d="M22.836 18.218a.75.75 0 0 0-.117-1.054l-2.5-2a.75.75 0 0 0-.938 1.172l2.5 2a.75.75 0 0 0 1.055-.117ZM1.282 17.164a.75.75 0 1 0 .937 1.172l2.5-2a.75.75 0 0 0-.937-1.172l-2.5 2ZM22.836 3.782a.75.75 0 0 1-.117 1.054l-2.5 2a.75.75 0 0 1-.938-1.172l2.5-2a.75.75 0 0 1 1.055.118Z" fill="currentColor"/></svg>
<span class="px-1">Upgrade now</span>
</a>
+1 -1
View File
@@ -1,5 +1,5 @@
shared: &shared
version: '4.3.0'
version: '4.2.0'
development:
<<: *shared
+3 -7
View File
@@ -169,10 +169,6 @@
- name: crm_integration
display_name: CRM Integration
enabled: false
- name: channel_voice
display_name: Voice Channel
enabled: false
chatwoot_internal: true
- name: notion_integration
display_name: Notion Integration
enabled: false
- name: github_integration
display_name: Github Integration
enabled: false
+14 -19
View File
@@ -288,25 +288,6 @@
type: secret
## ------ End of Configs added for Linear ------ ##
## ------ Configs added for Notion ------ ##
- name: NOTION_CLIENT_ID
display_title: 'Notion Client ID'
value:
locked: false
description: 'Notion client ID'
- name: NOTION_CLIENT_SECRET
display_title: 'Notion Client Secret'
value:
locked: false
description: 'Notion client secret'
type: secret
- name: NOTION_VERSION
display_title: 'Notion Version'
value: '2022-06-28'
locked: false
description: 'Notion version'
## ------ End of Configs added for Notion ------ ##
## ------ Configs added for Slack ------ ##
- name: SLACK_CLIENT_ID
display_title: 'Slack Client ID'
@@ -360,3 +341,17 @@
value: 'v22.0'
locked: true
# ------- End of Instagram Channel Related Config ------- #
## ------ Configs added for GitHub ------ ##
- name: GITHUB_CLIENT_ID
display_title: 'GitHub Client ID'
value:
locked: false
description: 'GitHub OAuth app client ID'
- name: GITHUB_CLIENT_SECRET
display_title: 'GitHub Client Secret'
value:
locked: false
description: 'GitHub OAuth app client secret'
type: secret
## ------ End of Configs added for GitHub ------ ##
+7 -6
View File
@@ -63,12 +63,6 @@ linear:
action: https://linear.app/oauth/authorize
hook_type: account
allow_multiple_hooks: false
notion:
id: notion
logo: notion.png
i18n_key: notion
hook_type: account
allow_multiple_hooks: false
slack:
id: slack
logo: slack.png
@@ -284,3 +278,10 @@ leadsquared:
'conversation_activity_score',
'transcript_activity_score',
]
github:
id: github
logo: github.png
i18n_key: github
hook_type: account
allow_multiple_hooks: false

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