Compare commits

..

2 Commits

Author SHA1 Message Date
Tanmay Deep Sharma
6730093a84 Merge branch 'develop' into feat/widget-visitor-token 2026-09-28 14:04:55 -07:00
Tanmay Deep Sharma
d63a71c59d feat(widget): carry the visitor's channel in the widget token and accept sessions without a contact 2026-09-28 12:34:30 -07:00
725 changed files with 8365 additions and 25901 deletions

View File

@@ -29,9 +29,3 @@ ignore:
# Patched 2.0 releases need an ai-agents migration (currently constrained to RubyLLM 1.x).
# Remove when a supported patched RubyLLM/ai-agents combination is adopted.
- CVE-2026-67991
# Think-tag parsing and Mistral matching ReDoS are reported on Ruby 3.1.
# We pin Ruby 3.4.4, where the affected regexes are linear-time.
# Remove when a supported patched RubyLLM/ai-agents combination is adopted;
# reassess if the supported Ruby runtime changes.
- CVE-2026-67987
- CVE-2026-67989

View File

@@ -23,7 +23,7 @@ FRONTEND_URL=http://0.0.0.0:3000
# CONVERSATION_MONITORS_LIMIT=20
# Allowlisted contact/conversation custom attribute keys to send as customer context
# CONVERSATION_MONITORS_ATTRIBUTES=deployment_type
# Each account can make 500,000 monitor calls per UTC day and 10,000,000 per UTC month.
# CONVERSATION_MONITORS_DAILY_TOKEN_LIMIT=20000000
# To use a dedicated URL for help center pages
# HELPCENTER_URL=http://0.0.0.0:3000

View File

@@ -1,41 +0,0 @@
name: Migration Version Check
on:
pull_request:
paths:
- 'db/migrate/**'
concurrency:
group: pr-${{ github.workflow }}-${{ github.head_ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
migration_version_check:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Reject hand-written migration versions
run: |
rounded=$(git diff --name-only --diff-filter=AR HEAD^1 HEAD -- db/migrate | grep -E '^db/migrate/[0-9]{10}0000_' || true)
if [ -n "$rounded" ]; then
echo "::error::Rounded migration versions collide across branches. Regenerate with 'bundle exec rails g migration':"
echo "$rounded"
exit 1
fi
- name: Reject duplicate migration versions
run: |
duplicates=$(ls db/migrate | cut -d_ -f1 | sort | uniq -d)
if [ -n "$duplicates" ]; then
echo "::error::Duplicate migration versions break db:migrate:"
echo "$duplicates"
exit 1
fi

View File

@@ -16,7 +16,6 @@ Layout/LineLength:
Metrics/ClassLength:
Max: 175
Exclude:
- 'app/builders/messages/message_builder.rb'
- 'app/models/message.rb'
- 'app/models/conversation.rb'
- 'config/initializers/rack_attack.rb'

View File

@@ -43,8 +43,6 @@
## General Guidelines
- **Database foreign keys**: Prefer Rails associations and callbacks over database foreign-key constraints. Add a database foreign key only when there is a clear need and after checking deletion behavior and production migration/locking risks. Existing foreign keys alone are not a reason to add new ones.
- **Migrations**: Create migrations with `bundle exec rails g migration <Name>` so the version is a real timestamp. Never hand-write or round the version (e.g. `20260925000000`); rounded versions collide across branches and break `db:migrate`.
- Prefer the smallest production-ready change that solves the current problem.
- Build for the expected production path first. Do not add speculative guards, fallbacks, retries, or edge-case handling unless the caller can actually hit that case or production has proven it necessary.
- Enforce eligibility and exclusivity rules at the earliest shared entry point. Do not repeat backup guards across downstream jobs, callbacks, services, or writes unless a proven independent path bypasses that point.

View File

@@ -58,17 +58,12 @@ class ContactMergeAction
# attributes in base contact are given preference
merged_attributes = mergee_contact_attributes.deep_merge(base_contact_attributes)
merged_attributes['contact_type'] = higher_contact_type
@mergee_contact.reload.destroy!
Rails.configuration.dispatcher.dispatch(CONTACT_MERGED, Time.zone.now, contact: @base_contact,
tokens: [@base_contact.contact_inboxes.filter_map(&:pubsub_token)])
@base_contact.update!(merged_attributes)
end
def higher_contact_type
[base_contact, mergee_contact].map(&:contact_type).max_by { |type| Contact.contact_types[type] }
end
end
ContactMergeAction.prepend_mod_with('ContactMergeAction')

View File

@@ -2,3 +2,4 @@
//= link administrate/application.css
//= link administrate/application.js
//= link administrate-field-active_storage/application.css
//= link secretField.js

View File

@@ -0,0 +1,45 @@
// eslint-disable-next-line
function toggleSecretField(e) {
e.preventDefault();
e.stopPropagation();
const toggler = e.currentTarget;
const secretField = toggler.parentElement;
const textElement = secretField.querySelector('[data-secret-masked]');
if (!textElement) return;
if (textElement.dataset.secretMasked === 'false') {
const maskedLength = secretField.dataset.secretText?.length || 10;
textElement.textContent = '•'.repeat(maskedLength);
textElement.dataset.secretMasked = 'true';
toggler.querySelector('svg use').setAttribute('xlink:href', '#eye-show');
return;
}
textElement.textContent = secretField.dataset.secretText;
textElement.dataset.secretMasked = 'false';
toggler.querySelector('svg use').setAttribute('xlink:href', '#eye-hide');
}
// eslint-disable-next-line
function copySecretField(e) {
e.preventDefault();
e.stopPropagation();
const toggler = e.currentTarget;
const secretField = toggler.parentElement;
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);
}
});
});

View File

@@ -1,5 +1,32 @@
@charset 'utf-8';
// The super admin is styled by app/javascript/entrypoints/superadmin.scss.
// Sprockets only serves the selectize base styles its pickers need.
@import 'reset/normalize';
@import 'utilities/variables';
@import 'utilities/text-color';
@import 'selectize';
@import 'library/clearfix';
@import 'library/data-label';
@import 'library/variables';
@import 'base/forms';
@import 'base/layout';
@import 'base/lists';
@import 'base/tables';
@import 'base/typography';
@import 'components/app-container';
@import 'components/attributes';
@import 'components/buttons';
@import 'components/cells';
@import 'components/field-unit';
@import 'components/flashes';
@import 'components/form-actions';
@import 'components/main-content';
@import 'components/pagination';
@import 'components/search';
@import 'components/reports';
@import 'custom_styles';

View File

@@ -0,0 +1,96 @@
fieldset {
background-color: transparent;
border: 0;
margin: 0;
padding: 0;
}
legend {
font-weight: $font-weight-medium;
margin: 0;
padding: 0;
}
label {
display: block;
font-weight: $font-weight-medium;
margin: 0;
}
input,
select {
display: block;
font-family: $base-font-family;
font-size: $base-font-size;
}
[type="color"],
[type="date"],
[type="datetime-local"],
[type="email"],
[type="month"],
[type="number"],
[type="password"],
[type="search"],
[type="tel"],
[type="text"],
[type="time"],
[type="url"],
[type="week"],
input:not([type]),
textarea {
appearance: none;
background-color: $white;
border: $base-border;
border-radius: $base-border-radius;
font-family: $base-font-family;
padding: 0.5em;
transition: border-color $base-duration $base-timing;
width: 100%;
&:hover {
border-color: mix($black, $base-border-color, 20%);
}
&:focus {
border-color: $action-color;
outline: none;
}
&:disabled {
background-color: mix($black, $white, 5%);
cursor: not-allowed;
&:hover {
border: $base-border;
}
}
}
textarea {
resize: vertical;
}
[type="checkbox"],
[type="radio"] {
display: inline;
margin-right: $small-spacing / 2;
}
[type="file"] {
width: 100%;
}
select {
width: 100%;
}
[type="checkbox"],
[type="radio"],
[type="file"],
select {
&:focus {
outline: $focus-outline;
outline-offset: $focus-outline-offset;
}
}

View File

@@ -0,0 +1,22 @@
html {
background-color: $color-white;
box-sizing: border-box;
font-size: 16px;
-webkit-font-smoothing: antialiased;
}
*,
*::before,
*::after {
box-sizing: inherit;
}
figure {
margin: 0;
}
img,
picture {
margin: 0;
max-width: 100%;
}

View File

@@ -0,0 +1,19 @@
ul,
ol {
list-style-type: none;
margin: 0;
padding: 0;
}
dl {
margin-bottom: $small-spacing;
dt {
font-weight: $font-weight-medium;
margin-top: $small-spacing;
}
dd {
margin: 0;
}
}

View File

@@ -0,0 +1,71 @@
table {
border-collapse: collapse;
font-size: $font-size-default;
text-align: left;
width: 100%;
a {
color: inherit;
text-decoration: none;
}
}
tr {
border-bottom: $base-border;
th {
font-weight: $font-weight-medium;
&.cell-label--avatar-field {
a {
display: none;
}
}
}
}
tbody tr {
&:hover {
background-color: $base-background-color;
cursor: pointer;
}
&:focus {
outline: $focus-outline;
outline-offset: -($focus-outline-width);
}
td {
&.cell-data--avatar-field {
line-height: 1;
text-align: center;
img {
border-radius: 50%;
height: $space-large;
max-height: $space-large;
width: $space-large;
}
}
}
}
td,
th {
padding: $space-slab;
vertical-align: middle;
}
td:first-child,
th:first-child {
padding-left: 0;
}
td:last-child,
th:last-child {
padding-right: 0;
}
td img {
max-height: 2rem;
}

View File

@@ -0,0 +1,44 @@
body {
color: $base-font-color;
font-family: $base-font-family;
font-size: $base-font-size;
line-height: $base-line-height;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: $heading-font-family;
font-size: $base-font-size;
line-height: $heading-line-height;
margin: 0;
}
p {
margin: 0 0 $small-spacing;
}
a {
color: $action-color;
transition: color $base-duration $base-timing;
&:hover {
color: mix($black, $action-color, 25%);
}
&:focus {
outline: $focus-outline;
outline-offset: $focus-outline-offset;
}
}
hr {
border-bottom: $base-border;
border-left: 0;
border-right: 0;
border-top: 0;
margin: $base-spacing 0;
}

View File

@@ -0,0 +1,8 @@
.app-container {
align-items: stretch;
display: flex;
margin-left: auto;
margin-right: auto;
max-width: 100rem;
min-height: 100vh;
}

View File

@@ -0,0 +1,26 @@
.attribute-label {
@include data-label;
clear: left;
float: left;
margin-bottom: $base-spacing;
margin-top: 0.25em;
text-align: left;
width: calc(16% - 1rem);
}
.preserve-whitespace {
white-space: pre-wrap;
word-wrap: break-word;
}
.attribute-data {
float: left;
margin-bottom: $base-spacing;
margin-left: 1.25rem;
width: calc(84% - 0.625rem);
}
.attribute--nested {
border: $base-border;
padding: $small-spacing;
}

View File

@@ -0,0 +1,50 @@
button:not(.reset-base),
input[type='button']:not(.reset-base),
input[type='reset']:not(.reset-base),
input[type='submit']:not(.reset-base),
.button:not(.reset-base) {
appearance: none;
background-color: $color-woot;
border: 0;
border-radius: $base-border-radius;
color: $white;
cursor: pointer;
display: inline-flex;
font-size: $font-size-small;
-webkit-font-smoothing: antialiased;
font-weight: $font-weight-medium;
line-height: 1;
padding: $space-one $space-two;
text-decoration: none;
transition: background-color $base-duration $base-timing;
user-select: none;
vertical-align: middle;
white-space: nowrap;
&:hover {
background-color: mix($black, $color-woot, 20%);
color: $white;
}
&:focus {
outline: $focus-outline;
outline-offset: $focus-outline-offset;
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
&:hover {
background-color: $color-woot;
}
}
}
.button--alt {
background-color: transparent;
border: $base-border;
border-color: $blue;
color: $blue;
margin-bottom: $base-spacing;
}

View File

@@ -0,0 +1,70 @@
.cell-label {
&:hover {
a {
color: $action-color;
}
svg {
fill: $action-color;
transform: rotate(180deg);
}
}
a {
color: inherit;
display: inline-block;
transition: color $base-duration $base-timing;
width: 100%;
}
}
.cell-label--asc,
.cell-label--desc {
font-weight: $font-weight-medium;
}
.cell-label__sort-indicator {
float: right;
margin-left: 5px;
svg {
fill: $hint-grey;
height: 13px;
transition: transform $base-duration $base-timing;
width: 13px;
}
}
.cell-label__sort-indicator--desc {
transform: rotate(180deg);
}
.cell-data--number,
.cell-label--number {
text-align: right;
}
.cell-data__secret-field {
align-items: center;
color: $hint-grey;
display: flex;
span {
flex: 0 0 auto;
}
[data-secret-toggler],
[data-secret-copier] {
background: transparent;
border: 0;
color: inherit;
margin-left: 0.5rem;
padding: 0;
svg {
fill: currentColor;
height: 1.25rem;
width: 1.25rem;
}
}
}

View File

@@ -0,0 +1,54 @@
.field-unit {
@include administrate-clearfix;
align-items: center;
display: flex;
margin-bottom: $base-spacing;
position: relative;
width: 100%;
}
.field-unit__label {
float: left;
margin-left: 0.625rem;
text-align: right;
width: calc(15% - 0.625rem);
}
.field-unit__field {
float: left;
margin-left: 1.25rem;
max-width: 31.15rem;
width: 100%;
}
.field-unit--nested {
border: $base-border;
margin-left: 7.5%;
max-width: 37.5rem;
padding: $small-spacing;
width: 100%;
.field-unit__field {
width: 100%;
}
.field-unit__label {
width: 10rem;
}
}
.field-unit--required {
label::after {
color: $red;
content: ' *';
}
}
.attribute-data--avatar-field {
height: $space-larger;
width: $space-larger;
img {
border-radius: 50%;
}
}

View File

@@ -0,0 +1,28 @@
$base-spacing: 1.5em !default;
$flashes: (
"alert": #fff6bf,
"error": #fbe3e4,
"notice": #e5edf8,
"success": #e6efc2,
) !default;
@each $flash-type, $color in $flashes {
.flash-#{$flash-type} {
background-color: $color;
color: mix($black, $color, 60%);
display: block;
margin-bottom: $base-spacing / 2;
padding: $base-spacing / 2;
text-align: center;
a {
color: mix($black, $color, 70%);
text-decoration: underline;
&:focus,
&:hover {
color: mix($black, $color, 90%);
}
}
}
}

View File

@@ -0,0 +1,3 @@
.form-actions {
margin-left: calc(15% + 1.25rem);
}

View File

@@ -0,0 +1,35 @@
.main-content {
font-size: $font-size-default;
left: 21rem;
position: absolute;
right: 0;
top: 0;
}
.main-content__body {
font-size: $font-size-small;
padding: $space-two;
table {
font-size: $font-size-small;
}
form {
margin-top: $space-two;
}
}
.main-content__header {
align-items: center;
background-color: $color-white;
border-bottom: 1px solid $color-border;
display: flex;
min-height: 3.5rem;
padding: $space-small $space-normal;
}
.main-content__page-title {
font-size: $font-size-medium;
font-weight: $font-weight-medium;
margin-right: auto;
}

View File

@@ -0,0 +1,19 @@
.pagination {
font-size: $font-size-default;
margin-top: $base-spacing;
padding-left: $base-spacing;
padding-right: $base-spacing;
text-align: center;
.first,
.prev,
.page,
.next,
.last {
margin: $small-spacing;
}
.current {
font-weight: $font-weight-medium;
}
}

View File

@@ -0,0 +1,15 @@
.report--list {
display: flex;
padding: 0 $space-two $space-larger;
}
.report-card {
flex: 1;
font-size: $font-size-small;
text-align: center;
.metric {
font-size: $font-size-bigger;
font-weight: 200;
}
}

View File

@@ -0,0 +1,44 @@
.search {
margin-left: auto;
margin-right: 1.25rem;
max-width: 27.5rem;
position: relative;
width: 100%;
}
.search__input {
background: $grey-1;
padding-left: $space-normal * 2.5;
padding-right: $space-normal * 2.5;
}
.search__eyeglass-icon {
fill: $grey-7;
height: $space-normal;
left: $space-normal;
position: absolute;
top: 50%;
transform: translateY(-50%);
width: $space-normal;
}
.search__clear-link {
height: $space-normal;
position: absolute;
right: $space-normal * 0.75;
top: 50%;
transform: translateY(-50%);
width: $space-normal;
}
.search__clear-icon {
fill: $grey-5;
height: $space-normal;
position: absolute;
transition: fill $base-duration $base-timing;
width: $space-normal;
&:hover {
fill: $action-color;
}
}

View File

@@ -0,0 +1,23 @@
// custom styles for the dashboard
.feature-cell {
background: $color-extra-light-blue;
border-radius: 10px;
float: left;
margin-left: 8px;
margin-top: 6px;
padding: 4px 12px;
.icon-container {
margin-right: 2px;
}
.value-container {
margin-left: 6px;
}
}
.feature-container {
max-width: 100rem;
}

View File

@@ -0,0 +1,7 @@
@mixin administrate-clearfix {
&::after {
clear: both;
content: '';
display: block;
}
}

View File

@@ -0,0 +1,8 @@
@mixin data-label {
color: $hint-grey;
font-size: 0.8em;
font-weight: 400;
letter-spacing: 0.0357em;
position: relative;
text-transform: uppercase;
}

View File

@@ -0,0 +1,61 @@
// Typography
$base-font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
"Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif !default;
$heading-font-family: $base-font-family !default;
$base-font-size: 16px !default;
$base-line-height: 1.5 !default;
$heading-line-height: 1.2 !default;
// Other Sizes
$base-border-radius: 4px !default;
$base-spacing: $base-line-height * 1em !default;
$small-spacing: $base-spacing / 2 !default;
// Colors
$white: #fff !default;
$black: #000 !default;
$blue: #1f93ff !default;
$red: #ff382d !default;
$light-yellow: #ffc532 !default;
$light-green: #44ce4b !default;
$grey-0: #f6f7f7 !default;
$grey-1: #f0f4f5 !default;
$grey-2: #cfd8dc !default;
$grey-5: #adb5bd !default;
$grey-7: #293f54 !default;
$hint-grey: #7b808c !default;
// Font Colors
$base-font-color: $grey-7 !default;
$action-color: $blue !default;
// Background Colors
$base-background-color: $grey-0 !default;
// Focus
$focus-outline-color: transparentize($action-color, 0.4);
$focus-outline-width: 3px;
$focus-outline: $focus-outline-width solid $focus-outline-color;
$focus-outline-offset: 1px;
// Flash Colors
$flash-colors: (
alert: $light-yellow,
error: $red,
notice: mix($white, $blue, 50%),
success: $light-green
);
// Border
$base-border-color: $grey-1 !default;
$base-border: 1px solid $base-border-color !default;
// Transitions
$base-duration: 250ms !default;
$base-timing: ease-in-out !default;

View File

@@ -0,0 +1,448 @@
/*! normalize.css v7.0.0 | MIT License | github.com/necolas/normalize.css */
/* Document
========================================================================== */
/**
* 1. Correct the line height in all browsers.
* 2. Prevent adjustments of font size after orientation changes in
* IE on Windows Phone and in iOS.
*/
html {
line-height: 1.15; /* 1 */
-ms-text-size-adjust: 100%; /* 2 */
-webkit-text-size-adjust: 100%; /* 2 */
-moz-osx-font-smoothing: grayscale;
}
/* Sections
========================================================================== */
/**
* Remove the margin in all browsers (opinionated).
*/
body {
margin: 0;
}
/**
* Add the correct display in IE 9-.
*/
article,
aside,
footer,
header,
nav,
section {
display: block;
}
/**
* Correct the font size and margin on `h1` elements within `section` and
* `article` contexts in Chrome, Firefox, and Safari.
*/
h1 {
font-size: 2em;
margin: 0.67em 0;
}
/* Grouping content
========================================================================== */
/**
* Add the correct display in IE 9-.
* 1. Add the correct display in IE.
*/
figcaption,
figure,
main { /* 1 */
display: block;
}
/**
* Add the correct margin in IE 8.
*/
figure {
margin: 1em 40px;
}
/**
* 1. Add the correct box sizing in Firefox.
* 2. Show the overflow in Edge and IE.
*/
hr {
box-sizing: content-box; /* 1 */
height: 0; /* 1 */
overflow: visible; /* 2 */
}
/**
* 1. Correct the inheritance and scaling of font size in all browsers.
* 2. Correct the odd `em` font sizing in all browsers.
*/
pre {
font-family: monospace, monospace; /* 1 */
font-size: 1em; /* 2 */
}
/* Text-level semantics
========================================================================== */
/**
* 1. Remove the gray background on active links in IE 10.
* 2. Remove gaps in links underline in iOS 8+ and Safari 8+.
*/
a {
background-color: transparent; /* 1 */
-webkit-text-decoration-skip: objects; /* 2 */
}
/**
* 1. Remove the bottom border in Chrome 57- and Firefox 39-.
* 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.
*/
abbr[title] {
border-bottom: none; /* 1 */
text-decoration: underline; /* 2 */
text-decoration: underline dotted; /* 2 */
}
/**
* Prevent the duplicate application of `bolder` by the next rule in Safari 6.
*/
b,
strong {
font-weight: inherit;
}
/**
* Add the correct font weight in Chrome, Edge, and Safari.
*/
b,
strong {
font-weight: bolder;
}
/**
* 1. Correct the inheritance and scaling of font size in all browsers.
* 2. Correct the odd `em` font sizing in all browsers.
*/
code,
kbd,
samp {
font-family: monospace, monospace; /* 1 */
font-size: 1em; /* 2 */
}
/**
* Add the correct font style in Android 4.3-.
*/
dfn {
font-style: italic;
}
/**
* Add the correct background and color in IE 9-.
*/
mark {
background-color: #ff0;
color: #000;
}
/**
* Add the correct font size in all browsers.
*/
small {
font-size: 80%;
}
/**
* Prevent `sub` and `sup` elements from affecting the line height in
* all browsers.
*/
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
/* Embedded content
========================================================================== */
/**
* Add the correct display in IE 9-.
*/
audio,
video {
display: inline-block;
}
/**
* Add the correct display in iOS 4-7.
*/
audio:not([controls]) {
display: none;
height: 0;
}
/**
* Remove the border on images inside links in IE 10-.
*/
img {
border-style: none;
}
/**
* Hide the overflow in IE.
*/
svg:not(:root) {
overflow: hidden;
}
/* Forms
========================================================================== */
/**
* 1. Change the font styles in all browsers (opinionated).
* 2. Remove the margin in Firefox and Safari.
*/
button,
input,
optgroup,
select,
textarea {
font-family: sans-serif; /* 1 */
font-size: 100%; /* 1 */
line-height: 1.15; /* 1 */
margin: 0; /* 2 */
}
/**
* Show the overflow in IE.
* 1. Show the overflow in Edge.
*/
button,
input { /* 1 */
overflow: visible;
}
/**
* Remove the inheritance of text transform in Edge, Firefox, and IE.
* 1. Remove the inheritance of text transform in Firefox.
*/
button,
select { /* 1 */
text-transform: none;
}
/**
* 1. Prevent a WebKit bug where (2) destroys native `audio` and `video`
* controls in Android 4.
* 2. Correct the inability to style clickable types in iOS and Safari.
*/
button,
html [type="button"], /* 1 */
[type="reset"],
[type="submit"] {
-webkit-appearance: button; /* 2 */
}
/**
* Remove the inner border and padding in Firefox.
*/
button::-moz-focus-inner,
[type="button"]::-moz-focus-inner,
[type="reset"]::-moz-focus-inner,
[type="submit"]::-moz-focus-inner {
border-style: none;
padding: 0;
}
/**
* Restore the focus styles unset by the previous rule.
*/
button:-moz-focusring,
[type="button"]:-moz-focusring,
[type="reset"]:-moz-focusring,
[type="submit"]:-moz-focusring {
outline: 1px dotted ButtonText;
}
/**
* Correct the padding in Firefox.
*/
fieldset {
padding: 0.35em 0.75em 0.625em;
}
/**
* 1. Correct the text wrapping in Edge and IE.
* 2. Correct the color inheritance from `fieldset` elements in IE.
* 3. Remove the padding so developers are not caught out when they zero out
* `fieldset` elements in all browsers.
*/
legend {
box-sizing: border-box; /* 1 */
color: inherit; /* 2 */
display: table; /* 1 */
max-width: 100%; /* 1 */
padding: 0; /* 3 */
white-space: normal; /* 1 */
}
/**
* 1. Add the correct display in IE 9-.
* 2. Add the correct vertical alignment in Chrome, Firefox, and Opera.
*/
progress {
display: inline-block; /* 1 */
vertical-align: baseline; /* 2 */
}
/**
* Remove the default vertical scrollbar in IE.
*/
textarea {
overflow: auto;
}
/**
* 1. Add the correct box sizing in IE 10-.
* 2. Remove the padding in IE 10-.
*/
[type="checkbox"],
[type="radio"] {
box-sizing: border-box; /* 1 */
padding: 0; /* 2 */
}
/**
* Correct the cursor style of increment and decrement buttons in Chrome.
*/
[type="number"]::-webkit-inner-spin-button,
[type="number"]::-webkit-outer-spin-button {
height: auto;
}
/**
* 1. Correct the odd appearance in Chrome and Safari.
* 2. Correct the outline style in Safari.
*/
[type="search"] {
-webkit-appearance: textfield; /* 1 */
outline-offset: -2px; /* 2 */
}
/**
* Remove the inner padding and cancel buttons in Chrome and Safari on macOS.
*/
[type="search"]::-webkit-search-cancel-button,
[type="search"]::-webkit-search-decoration {
-webkit-appearance: none;
}
/**
* 1. Correct the inability to style clickable types in iOS and Safari.
* 2. Change font properties to `inherit` in Safari.
*/
::-webkit-file-upload-button {
-webkit-appearance: button; /* 1 */
font: inherit; /* 2 */
}
/* Interactive
========================================================================== */
/*
* Add the correct display in IE 9-.
* 1. Add the correct display in Edge, IE, and Firefox.
*/
details, /* 1 */
menu {
display: block;
}
/*
* Add the correct display in all browsers.
*/
summary {
display: list-item;
}
/* Scripting
========================================================================== */
/**
* Add the correct display in IE 9-.
*/
canvas {
display: inline-block;
}
/**
* Add the correct display in IE.
*/
template {
display: none;
}
/* Hidden
========================================================================== */
/**
* Add the correct display in IE 10-.
*/
[hidden] {
display: none;
}

View File

@@ -0,0 +1,3 @@
.text-color-red {
color: $alert-color;
}

View File

@@ -0,0 +1,90 @@
// Font sizes
$font-size-nano: 0.5rem;
$font-size-micro: 0.675rem;
$font-size-mini: 0.75rem;
$font-size-small: 0.875rem;
$font-size-default: 1rem;
$font-size-medium: 1.125rem;
$font-size-large: 1.375rem;
$font-size-big: 1.5rem;
$font-size-bigger: 1.75rem;
$font-size-mega: 2.125rem;
$font-size-giga: 2.5rem;
// spaces
$zero: 0;
$space-micro: 0.125rem;
$space-smaller: 0.25rem;
$space-small: 0.5rem;
$space-one: 0.675rem;
$space-slab: 0.75rem;
$space-normal: 1rem;
$space-two: 1.25rem;
$space-medium: 1.5rem;
$space-large: 2rem;
$space-larger: 3rem;
$space-jumbo: 4rem;
$space-mega: 6.25rem;
// font-weight
$font-weight-feather: 100;
$font-weight-light: 300;
$font-weight-normal: 400;
$font-weight-medium: 500;
$font-weight-bold: 600;
$font-weight-black: 700;
//Navbar
$nav-bar-width: 23rem;
$header-height: 5.6rem;
$woot-logo-padding: $space-large $space-two;
// Colors
$color-woot: #1f93ff;
$color-gray: #6e6f73;
$color-light-gray: #747677;
$color-border: #e0e6ed;
$color-border-light: #f0f4f5;
$color-background: #f4f6fb;
$color-border-dark: #cad0d4;
$color-background-light: #f9fafc;
$color-white: #fff;
$color-body: #3c4858;
$color-heading: #1f2d3d;
$color-extra-light-blue: #f5f7f9;
$primary-color: $color-woot;
$secondary-color: #5d7592;
$success-color: #44ce4b;
$warning-color: #ffc532;
$alert-color: #ff382d;
$masked-bg: rgba(0, 0, 0, .4);
// Color-palettes
$color-primary-light: #c7e3ff;
$color-primary-dark: darken($color-woot, 20%);
// Thumbnail
$thumbnail-radius: 4rem;
// chat-header
$conv-header-height: 4rem;
// Inbox List
$inbox-thumb-size: 4.8rem;
// Snackbar default
$woot-snackbar-bg: #323232;
$woot-snackbar-button: #ffeb3b;
$swift-ease-out-duration: .4s !default;
$swift-ease-out-timing-function: cubic-bezier(.25, .8, .25, 1) !default;
$swift-ease-out: all $swift-ease-out-duration $swift-ease-out-timing-function !default;
// Transitions
$transition-ease-in: all 0.250s ease-in;

View File

@@ -70,17 +70,12 @@ class AccountBuilder
end
def create_user
@user = build_user
@user.save!
end
def build_user
@user = User.new(email: @email,
password: user_password,
password_confirmation: user_password,
name: user_full_name)
@user.type = 'SuperAdmin' if @super_admin
@user.confirm if @confirmed
@user
@user.save!
end
end

View File

@@ -2,7 +2,7 @@
# For Specific Channels like whatsapp, email etc . it smartly generated appropriate the source id when none is provided.
class ContactInboxBuilder
pattr_initialize [:contact, :inbox, :source_id, { hmac_verified: false }]
pattr_initialize [:contact, :inbox, :source_id, :pubsub_token, { hmac_verified: false }]
def perform
@source_id ||= generate_source_id
@@ -65,7 +65,8 @@ class ContactInboxBuilder
source_id: @source_id
}
::ContactInbox.where(attrs).first_or_create!(hmac_verified: hmac_verified || false)
# A nil pubsub_token is filled in by has_secure_token; the widget supplies the one its visitor already streams on.
::ContactInbox.where(attrs).first_or_create!(hmac_verified: hmac_verified || false, pubsub_token: pubsub_token)
rescue ActiveRecord::RecordNotUnique
Rails.logger.info("[ContactInboxBuilder] RecordNotUnique #{@source_id} #{@contact.id} #{@inbox.id}")
update_old_contact_inbox

View File

@@ -3,7 +3,7 @@
# for contact inbox logic it uses the contact inbox builder
class ContactInboxWithContactBuilder
pattr_initialize [:inbox!, :contact_attributes!, :source_id, :hmac_verified]
pattr_initialize [:inbox!, :contact_attributes!, :source_id, :pubsub_token, :hmac_verified]
def perform
find_or_create_contact_and_contact_inbox
@@ -40,6 +40,7 @@ class ContactInboxWithContactBuilder
contact: @contact,
inbox: @inbox,
source_id: @source_id,
pubsub_token: pubsub_token,
hmac_verified: hmac_verified
).perform
end

View File

@@ -7,7 +7,7 @@ class Messages::MessageBuilder
def initialize(user, conversation, params)
@params = params
@private = ActiveModel::Type::Boolean.new.cast(params[:private]) || false
@private = params[:private] || false
@conversation = conversation
@user = user
@account = conversation.account
@@ -49,7 +49,9 @@ class Messages::MessageBuilder
end
def process_attachments
(Array(@attachments) + forwarded_attachments).each do |uploaded_attachment|
return if @attachments.blank?
@attachments.each do |uploaded_attachment|
attachment = @message.attachments.build(
account_id: @message.account_id,
file: uploaded_attachment
@@ -74,27 +76,7 @@ class Messages::MessageBuilder
attachment.meta = (attachment.meta || {}).merge('is_voice_message' => true)
end
def forwarded_message_id
content_attributes[:forwarded_message_id]
end
def forwarded_attachments
return [] if forwarded_message_id.blank?
forwarded_message = @conversation.messages.find(forwarded_message_id)
forwarded_message.attachments.where(id: @params[:forwarded_attachment_ids]).map { |attachment| attachment.file.blob }
end
def validate_forward
return if forwarded_message_id.blank?
raise StandardError, 'Forwarded emails need an email inbox' unless @conversation.inbox.email?
raise StandardError, 'Forwarded emails cannot be private' if @private
raise StandardError, 'Forwarded emails need a recipient' if process_email_string(@params[:to_emails]).empty?
end
def process_emails
validate_forward
return unless @conversation.inbox&.inbox_type == 'Email'
cc_emails = process_email_string(@params[:cc_emails])

View File

@@ -10,6 +10,7 @@ class RoomChannel < ApplicationCable::Channel
end
def update_presence
adopt_visitor_contact
update_subscription
broadcast_presence
end
@@ -30,7 +31,8 @@ class RoomChannel < ApplicationCable::Channel
end
def update_subscription
return if @current_account.blank?
# A visitor whose contact does not exist yet has nothing to track.
return if @current_user.blank?
::OnlineStatusTracker.update_presence(@current_account.id, @current_user.class.name, @current_user.id)
end
@@ -41,19 +43,35 @@ class RoomChannel < ApplicationCable::Channel
def current_user
@current_user ||= if params[:user_id].blank?
ContactInbox.find_by!(pubsub_token: pubsub_token).contact
ContactInbox.find_by(pubsub_token: pubsub_token)&.contact
else
User.find_by!(pubsub_token: pubsub_token, id: params[:user_id])
end
end
def current_account
return if current_user.blank?
@current_account ||= if @current_user.is_a? Contact
@current_account ||= if @current_user.is_a?(User)
@current_user.accounts.find(params[:account_id])
elsif @current_user
@current_user.account
else
@current_user.accounts.find(params[:account_id])
visitor_account
end
end
# Until the visitor's contact exists, the signed widget token is the only proof the stream is theirs;
# it also names the inbox whose agent presence they should see.
def visitor_account
visitor_token = ::Widget::TokenService.new(token: params[:auth_token]).decode_token
raise ActiveRecord::RecordNotFound if visitor_token[:pubsub_token].blank? || visitor_token[:pubsub_token] != pubsub_token
::Inbox.find(visitor_token[:inbox_id]).account
end
# The contact is created lazily (WebsiteTokenHelper#ensure_contact); pick it up on the next ping.
def adopt_visitor_contact
return if @current_user.present? || params[:user_id].present?
@current_user = ContactInbox.find_by(pubsub_token: pubsub_token)&.contact
end
end

View File

@@ -88,5 +88,3 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
@automation_rule = Current.account.automation_rules.find_by(id: params[:id])
end
end
Api::V1::Accounts::AutomationRulesController.prepend_mod_with('Api::V1::Accounts::AutomationRulesController')

View File

@@ -1,6 +1,4 @@
class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseController
before_action :validate_assignment_targets, only: :create, if: -> { normalized_type == 'Conversation' }
def create
case normalized_type
when 'Conversation'
@@ -21,19 +19,6 @@ class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseControll
params[:type].to_s.camelize
end
def validate_assignment_targets
fields = conversation_params[:fields] || {}
assignee_id = fields[:assignee_id]
return render_could_not_create_error('Invalid assignee_id') if !assignee_id.nil? && !@current_account.users.exists?(id: assignee_id)
team_id = fields[:team_id]
# The dashboard sends 0 to remove the team assignment; accept '0' from form-encoded requests too.
return if [nil, 0, '0'].include?(team_id) || @current_account.teams.exists?(id: team_id)
render_could_not_create_error('Invalid team_id')
end
def enqueue_conversation_job
::BulkActionsJob.perform_later(
account: @current_account,

View File

@@ -31,6 +31,15 @@ class Api::V1::Accounts::Captain::TasksController < Api::V1::Accounts::BaseContr
render_result(result)
end
def label_suggestion
result = Captain::LabelSuggestionService.new(
account: Current.account,
conversation_display_id: params[:conversation_display_id]
).perform
render_result(result)
end
def follow_up
result = Captain::FollowUpService.new(
account: Current.account,

View File

@@ -1,5 +1,5 @@
class Api::V1::Accounts::Companies::ContactsController < Api::V1::Accounts::Companies::BaseController
RESULTS_PER_PAGE = 25
RESULTS_PER_PAGE = 15
CONTACT_SEARCH_QUERY = [
'contacts.name ILIKE :search',
'contacts.email ILIKE :search',
@@ -13,9 +13,7 @@ class Api::V1::Accounts::Companies::ContactsController < Api::V1::Accounts::Comp
before_action :fetch_contact, only: [:destroy]
def index
contacts = @company.contacts.order(:name, :id)
contacts = contacts.where(CONTACT_SEARCH_QUERY, search: "%#{params[:q].strip}%") if params[:q].present?
@contacts = fetch_contacts(contacts)
@contacts = fetch_contacts(@company.contacts.order(:name, :id))
@contacts_count = @contacts.total_count
end

View File

@@ -1,38 +1,15 @@
class Api::V1::Accounts::Companies::ConversationsController < Api::V1::Accounts::Companies::BaseController
before_action :authorize_company_read!
before_action :set_counts
def index
@conversations = Conversations::SortService.apply(company_conversations, params[:sort_by])
.includes(:assignee, :contact, :inbox, :taggings)
.preload(ai_assignee: { avatar_attachment: [:blob] })
.page(params[:page] || 1)
end
conversations = Current.account.conversations.includes(
:assignee, :contact, :inbox, :taggings
).preload(ai_assignee: { avatar_attachment: [:blob] }).where(contact_id: @company.contacts.select(:id))
def filter
@conversations = Companies::ConversationFilterService.new(
params.permit!, Current.user, Current.account, company: @company
).perform[:conversations]
render :index
rescue CustomExceptions::CustomFilter::InvalidAttribute,
CustomExceptions::CustomFilter::InvalidOperator,
CustomExceptions::CustomFilter::InvalidQueryOperator,
CustomExceptions::CustomFilter::InvalidValue => e
render_could_not_create_error(e.message)
end
private
def company_conversations
@company_conversations ||= Conversations::PermissionFilterService.new(
Current.account.conversations.where(contact_id: @company.contacts.select(:id)),
@conversations = Conversations::PermissionFilterService.new(
conversations,
Current.user,
Current.account
).perform
end
def set_counts
@all_count = company_conversations.count
@open_count = company_conversations.open.count
).perform.order(last_activity_at: :desc).limit(20)
end
end

View File

@@ -1,14 +1,11 @@
class Api::V1::Accounts::Companies::NotesController < Api::V1::Accounts::Companies::BaseController
RESULTS_PER_PAGE = 20
before_action :authorize_company_read!
def index
notes = Current.account.notes.where(contact_id: @company.contacts.select(:id))
notes = notes.where('notes.content ILIKE ?', "%#{Note.sanitize_sql_like(params[:q].strip)}%") if params[:q].present?
@notes = notes.latest
.includes(:contact, :user)
.page(params[:page] || 1)
.per(RESULTS_PER_PAGE)
@notes = Current.account.notes
.where(contact_id: @company.contacts.select(:id))
.latest
.includes(:contact, :user)
.limit(20)
end
end

View File

@@ -6,13 +6,12 @@ class Api::V1::Accounts::CompaniesController < Api::V1::Accounts::BaseController
sort_on :last_activity_at, internal_name: :order_on_last_activity_at, type: :scope, scope_params: [:direction]
sort_on :contacts_count, internal_name: :order_on_contacts_count, type: :scope, scope_params: [:direction]
RESULTS_PER_PAGE = 50
RESULTS_PER_PAGE = 25
before_action :ensure_companies_enabled!
before_action :check_authorization
before_action :set_current_page, only: [:index, :search]
before_action :fetch_company, only: [:show, :update, :destroy, :avatar, :destroy_custom_attributes]
before_action :validate_social_profiles, only: [:create, :update]
def index
@companies = fetch_companies(resolved_companies)
@@ -84,14 +83,6 @@ class Api::V1::Accounts::CompaniesController < Api::V1::Accounts::BaseController
@company = Current.account.companies.find(params[:id])
end
def validate_social_profiles
social_profiles = params.dig(:company, :additional_attributes, :social_profiles)
return unless social_profiles.is_a?(ActionController::Parameters)
return if social_profiles.values.all? { |url| url.to_s.match?(%r{\Ahttps?://}i) }
render json: { error: I18n.t('errors.companies.social_profiles.invalid') }, status: :unprocessable_entity
end
def company_params
params.require(:company).permit(
:name,

View File

@@ -84,7 +84,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def create
ActiveRecord::Base.transaction do
@contact = Current.account.contacts.new(permitted_params.except(:avatar_url).merge(contact_type: :lead))
@contact = Current.account.contacts.new(permitted_params.except(:avatar_url))
@contact.save!
@contact_inbox = build_contact_inbox
process_avatar_from_url

View File

@@ -1,14 +1,8 @@
class Api::V1::Accounts::Conversations::ContactInfoRequestsController < Api::V1::Accounts::Conversations::BaseController
rescue_from CustomExceptions::WhatsappContactInfoRequestError do |error|
render json: { error: error.message }, status: error.http_status
end
def show
render json: Whatsapp::ContactInfoRequestEligibilityService.new(conversation: @conversation).availability
end
def create
@message = Whatsapp::ContactInfoRequestService.new(conversation: @conversation, sender: Current.user).perform
render 'api/v1/accounts/conversations/messages/create'
rescue CustomExceptions::WhatsappContactInfoRequestError => e
render json: { error: e.message }, status: e.http_status
end
end

View File

@@ -20,9 +20,7 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
def destroy
ActiveRecord::Base.transaction do
# keeps the forward marker so replies to a deleted forward stay out of this conversation
message.update!(content: I18n.t('conversations.messages.deleted'), content_type: :text,
content_attributes: message.content_attributes.slice('forwarded_message_id').merge(deleted: true))
message.update!(content: I18n.t('conversations.messages.deleted'), content_type: :text, content_attributes: { deleted: true })
message.attachments.destroy_all
end
end
@@ -71,14 +69,21 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
next false unless message.failed?
Messages::StatusUpdateService.new(message, 'sent').perform
clear_source_id unless @conversation.inbox.api? || @conversation.inbox.web_widget?
previous_source_id = message.source_id
retry_attributes = { content_attributes: retry_content_attributes }
retry_attributes[:source_id] = nil unless @conversation.inbox.api? || @conversation.inbox.web_widget?
message.update!(retry_attributes)
if retry_attributes.key?(:source_id) && previous_source_id.present?
Rails.logger.info "Cleared older source ID #{previous_source_id} for message #{message.id}"
end
true
end
end
def clear_source_id
Rails.logger.info "Cleared older source ID #{message.source_id} for message #{message.id}" if message.source_id.present?
message.update!(source_id: nil)
def retry_content_attributes
return message.content_attributes if message.content_attributes.dig('whatsapp_contact_info', 'type') == 'request'
{}
end
def permitted_params

View File

@@ -3,10 +3,9 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
include DateRangeHelper
include HmacConcern
include ConversationCustomAttributesConcern
include ConversationAssignmentTargetsConcern
before_action :conversation, except: [:index, :meta, :search, :create, :filter]
before_action :validate_assignment_targets, :inbox, :contact, :contact_inbox, only: [:create]
before_action :inbox, :contact, :contact_inbox, only: [:create]
ATTACHMENT_RESULTS_PER_PAGE = 100

View File

@@ -10,17 +10,14 @@ class Api::V1::Accounts::Integrations::AppsController < Api::V1::Accounts::BaseC
private
def fetch_apps
@apps = Integrations::App.all.select do |app|
app.active?(Current.account) ||
(app.id == 'stripe' && Current.account_user&.administrator? && Current.account.hooks.exists?(app_id: 'stripe'))
end
@apps = Integrations::App.all.select { |app| app.active?(Current.account) }
end
def fetch_app
@app = Integrations::App.find(id: params[:id])
return unless %w[shopify stripe].include?(@app&.id)
return unless @app&.id == 'shopify'
# Keep direct lookups subject to the same availability checks as the integrations list.
# Keep direct Shopify lookups subject to the same availability checks as the integrations list.
raise ActiveRecord::RecordNotFound unless @app.active?(Current.account)
end
end

View File

@@ -2,7 +2,6 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Inte
before_action :fetch_hook, except: [:create]
before_action :check_authorization
before_action :ensure_shopify_enabled, if: :shopify_hook?
before_action :reject_stripe_hook
def create
@hook = Current.account.hooks.create!(permitted_params)
@@ -33,14 +32,6 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Inte
private
def reject_stripe_hook
app_id = action_name == 'create' ? permitted_params[:app_id] : @hook.app_id
return unless app_id == 'stripe'
# Stripe credentials and connection changes are managed by its OAuth and disconnect endpoints.
head :not_found
end
def fetch_hook
@hook = Current.account.hooks.find(params[:id])
end

View File

@@ -1,27 +1,25 @@
class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::Integrations::BaseController
include Shopify::IntegrationHelper
before_action :ensure_shopify_enabled
before_action -> { Shopify::ApiContext.setup! }, only: [:orders]
before_action :fetch_hook, except: [:auth, :complete_install]
before_action :check_authorization, only: [:auth, :complete_install, :destroy]
before_action :setup_shopify_context, only: [:orders]
before_action :fetch_hook, except: [:auth]
before_action :check_authorization, only: [:destroy]
before_action :validate_contact, only: [:orders]
def auth
shop_domain = params[:shop_domain]
unless shop_domain.is_a?(String) && Shopify::ShopDomain.valid?(shop_domain)
return render json: { error: 'Invalid Shopify shop domain' }, status: :unprocessable_entity
end
return render json: { error: 'Shop domain is required' }, status: :unprocessable_entity if shop_domain.blank?
state = generate_shopify_token(Current.account.id)
raise 'Shopify OAuth is not configured' if client_id.blank? || state.blank?
oauth_client = OAuth2::Client.new(client_id, client_secret,
site: "https://#{Shopify::ShopDomain.normalize(shop_domain)}",
authorize_url: '/admin/oauth/authorize')
render json: { redirect_url: oauth_client.auth_code.authorize_url(
redirect_uri: "#{ENV.fetch('FRONTEND_URL', '')}/shopify/callback",
scope: REQUIRED_SCOPES.join(','), state: state
) }
auth_url = "https://#{shop_domain}/admin/oauth/authorize?"
auth_url += URI.encode_www_form(
client_id: client_id,
scope: REQUIRED_SCOPES.join(','),
redirect_uri: redirect_uri,
state: state
)
render json: { redirect_url: auth_url }
end
def orders
@@ -34,25 +32,6 @@ class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::In
render json: { error: e.message }, status: :unprocessable_entity
end
def complete_install
pending_installation = Shopify::PendingInstallation.claim(
token: params[:pending_install_token]
)
Shopify::InstallationService.new(account: Current.account, pending_installation: pending_installation).perform
head :ok
rescue Shopify::PendingInstallation::AlreadyClaimed => e
pending_installation&.release!
render json: { error: e.message }, status: :conflict
rescue Shopify::PendingInstallation::CommitOutcomeUnknown
raise
rescue Shopify::PendingInstallation::Error => e
pending_installation&.release!
render json: { error: e.message }, status: :unprocessable_entity
rescue StandardError
pending_installation&.release!
raise
end
def destroy
@hook.destroy!
head :ok
@@ -62,8 +41,8 @@ class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::In
private
def ensure_shopify_enabled
head :not_found unless Shopify::FeatureGate.enabled?(account: Current.account)
def redirect_uri
"#{ENV.fetch('FRONTEND_URL', '')}/shopify/callback"
end
def contact
@@ -71,9 +50,7 @@ class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::In
end
def fetch_hook
hooks = Integrations::Hook.where(account: Current.account, app_id: 'shopify')
hooks = hooks.enabled if action_name == 'orders'
@hook = hooks.first!
@hook = Integrations::Hook.find_by!(account: Current.account, app_id: 'shopify')
end
def fetch_customers
@@ -105,15 +82,25 @@ class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::In
end
end
def setup_shopify_context
return if client_id.blank? || client_secret.blank?
ShopifyAPI::Context.setup(
api_key: client_id,
api_secret_key: client_secret,
api_version: '2025-01'.freeze,
scope: REQUIRED_SCOPES.join(','),
is_embedded: true,
is_private: false
)
end
def shopify_session
ShopifyAPI::Auth::Session.new(shop: @hook.reference_id, access_token: @hook.access_token)
end
def shopify_client
@shopify_client ||= ShopifyAPI::Clients::Rest::Admin.new(
session: shopify_session,
api_version: Shopify::ApiContext::API_VERSION
)
@shopify_client ||= ShopifyAPI::Clients::Rest::Admin.new(session: shopify_session)
end
def validate_contact

View File

@@ -1,58 +0,0 @@
class Api::V1::Accounts::Integrations::StripeController < Api::V1::Accounts::Integrations::BaseController
before_action :ensure_configured, only: [:auth, :customer]
before_action :check_admin_authorization?, only: [:show, :auth, :destroy]
def show
hook = Current.account.hooks.find_by!(app_id: 'stripe')
mode = Integrations::Stripe::Connection.new(hook).livemode? ? 'live' : 'sandbox'
render json: { account_id: hook.reference_id, connected_at: hook.settings.fetch('connected_at', hook.created_at), mode: mode,
reauthorization_required: hook.reauthorization_required? }
end
def auth
url = Integrations::Stripe::Oauth.authorize_url(account: Current.account, user: Current.user)
state = URI.decode_www_form(URI(url).query).to_h.fetch('state')
cookies.signed[:stripe_oauth_state] = { value: state, httponly: true, secure: request.ssl?, same_site: :lax,
expires: Integrations::Stripe::Oauth::STATE_TTL.from_now }
render json: { url: url }
end
def destroy
Current.account.hooks.find_by!(app_id: 'stripe').destroy!
head :no_content
end
def customer
conversation_id = params.require(:conversation_id)
unless conversation_id.is_a?(String) && conversation_id.match?(/\A[1-9]\d*\z/)
return render_could_not_create_error('conversation_id must be a positive integer')
end
conversation = Current.account.conversations.find_by!(display_id: conversation_id)
authorize conversation, :show?
hook = Current.account.hooks.find_by!(app_id: 'stripe', status: :enabled)
render json: Integrations::Stripe::CustomerSummary.new(connection: Integrations::Stripe::Connection.new(hook),
contact: conversation.contact).perform(customer_id: params[:customer_id])
rescue Integrations::Stripe::Connection::ReauthorizationRequired, ::Stripe::AuthenticationError
hook.prompt_reauthorization!
render json: { error: 'stripe_reauthorization_required' }, status: :unprocessable_entity
rescue OAuth2::Error => e
hook.prompt_reauthorization! if e.code == 'invalid_grant'
render json: { error: 'stripe_unavailable' }, status: :unprocessable_entity
rescue ::Stripe::StripeError, Faraday::TimeoutError, Faraday::ConnectionFailed => e
render_customer_failure(e)
end
private
def render_customer_failure(error)
details = { error: error.class.name }
details.merge!(code: error.code, status: error.http_status, request_id: error.request_id) if error.is_a?(::Stripe::StripeError)
Rails.logger.warn(details.compact.to_json)
render json: { error: 'stripe_unavailable' }, status: :unprocessable_entity
end
def ensure_configured
head :not_found unless Integrations::App.find(id: 'stripe').active?(Current.account)
end
end

View File

@@ -18,7 +18,6 @@ class Api::V1::AccountsController < Api::BaseController
CustomExceptions::Account::UserExists,
CustomExceptions::Account::UserErrors,
with: :render_error_response
rescue_from Shopify::PendingInstallation::Error, with: :render_shopify_signup_error
def show
@latest_chatwoot_version = latest_chatwoot_version
@@ -26,8 +25,14 @@ class Api::V1::AccountsController < Api::BaseController
end
def create
builder_class = account_params[:shopify_pending_install_token].present? ? Shopify::SignupService : AccountBuilder
@user, @account = builder_class.new(**account_builder_params).perform
@user, @account = AccountBuilder.new(
account_name: account_params[:account_name],
user_full_name: account_params[:user_full_name],
email: account_params[:email],
user_password: account_params[:password],
locale: account_params[:locale],
user: current_user
).perform
enqueue_branding_enrichment
if @user
# Authenticated users (dashboard "add account") and api_only signups
@@ -72,20 +77,6 @@ class Api::V1::AccountsController < Api::BaseController
Redis::Alfred.get(Redis::Alfred::LATEST_CHATWOOT_VERSION)
end
def account_builder_params
attributes = {
account_name: account_params[:account_name],
user_full_name: account_params[:user_full_name],
email: account_params[:email],
user_password: account_params[:password],
locale: account_params[:locale],
user: current_user
}
pending_install_token = account_params[:shopify_pending_install_token]
attributes[:shopify_pending_install_token] = pending_install_token if pending_install_token.present?
attributes
end
def enqueue_branding_enrichment
email = account_params[:email].presence || @user&.email
return if email.blank?
@@ -120,22 +111,7 @@ class Api::V1::AccountsController < Api::BaseController
end
def account_params
params.permit(
:account_name,
:email,
:name,
:password,
:locale,
:domain,
:support_email,
:user_full_name,
:shopify_pending_install_token
)
end
def render_shopify_signup_error(exception)
log_handled_error(exception)
render json: { message: exception.message }, status: :unprocessable_entity
params.permit(:account_name, :email, :name, :password, :locale, :domain, :support_email, :user_full_name)
end
def custom_attributes_params
@@ -153,14 +129,7 @@ class Api::V1::AccountsController < Api::BaseController
end
def check_signup_enabled
return if GlobalConfigService.account_signup_enabled? || pending_shopify_signup?
raise ActionController::RoutingError, 'Not Found'
end
def pending_shopify_signup?
token = account_params[:shopify_pending_install_token]
Shopify::FeatureGate.enabled? && Shopify::PendingInstallation.pending?(token: token)
raise ActionController::RoutingError, 'Not Found' unless GlobalConfigService.account_signup_enabled?
end
def api_only_signup?

View File

@@ -1,4 +1,6 @@
class Api::V1::Integrations::WebhooksController < ApplicationController
SIGNATURE_TOLERANCE = 5.minutes.to_i
prepend_before_action :verify_slack_signature!, only: [:create]
def create
@@ -11,12 +13,31 @@ class Api::V1::Integrations::WebhooksController < ApplicationController
# Skip (rather than reject) when no secret is configured, so existing installs keep working.
def verify_slack_signature!
if Integrations::Slack::SignatureVerifier.signing_secret.blank?
secret = slack_signing_secret
if secret.blank?
Rails.logger.warn('[SLACK] SLACK_SIGNING_SECRET not configured; skipping webhook signature verification')
return
end
head :unauthorized unless Integrations::Slack::SignatureVerifier.valid?(request)
head :unauthorized unless valid_slack_signature?(secret)
end
# Config reconciliation creates a blank row for this key on upgrade, and that blank row
# makes GlobalConfigService skip its ENV fallback, so read ENV directly as a last resort.
def slack_signing_secret
GlobalConfigService.load('SLACK_SIGNING_SECRET', nil).presence || ENV.fetch('SLACK_SIGNING_SECRET', nil)
end
def valid_slack_signature?(secret)
timestamp = request.headers['X-Slack-Request-Timestamp']
signature = request.headers['X-Slack-Signature']
return false if timestamp.blank? || signature.blank?
return false if (Time.current.to_i - timestamp.to_i).abs > SIGNATURE_TOLERANCE
# Build over raw bytes so a payload with invalid UTF-8 can't raise on interpolation.
basestring = "v0:#{timestamp}:".b << request.raw_post
expected = "v0=#{OpenSSL::HMAC.hexdigest('SHA256', secret, basestring)}"
ActiveSupport::SecurityUtils.secure_compare(expected, signature)
end
# TODO: This is a temporary solution to permit all params for slack unfurling job.

View File

@@ -8,6 +8,8 @@ class Api::V1::Widget::BaseController < ApplicationController
private
def conversations
return ::Conversation.none if @contact_inbox.nil?
if @contact_inbox.hmac_verified?
verified_contact_inbox_ids = @contact.contact_inboxes.where(inbox_id: auth_token_params[:inbox_id], hmac_verified: true).map(&:id)
@conversations = @contact.conversations.where(contact_inbox_id: verified_contact_inbox_ids)

View File

@@ -1,9 +1,14 @@
class Api::V1::Widget::ConfigsController < Api::V1::Widget::BaseController
include WidgetHelper
before_action :set_global_config
def create
build_contact
set_token
@contact_inbox ||= @web_widget.create_contact_inbox(
source_id: auth_token_params[:source_id], pubsub_token: auth_token_params[:pubsub_token], additional_attributes: additional_attributes
)
@contact = @contact_inbox.contact
@token, @pubsub_token = visitor_session(@web_widget, request.headers['X-Auth-Token'], auth_token_params, @contact_inbox)
end
private
@@ -18,25 +23,12 @@ class Api::V1::Widget::ConfigsController < Api::V1::Widget::BaseController
)
end
# Unlike the other widget endpoints, this one also serves visitors without a token.
def set_contact
@contact_inbox = @web_widget.inbox.contact_inboxes.find_by(
source_id: auth_token_params[:source_id]
)
@contact_inbox = @web_widget.inbox.contact_inboxes.find_by(source_id: auth_token_params[:source_id])
@contact = @contact_inbox&.contact
end
def build_contact
return if @contact.present?
@contact_inbox = @web_widget.create_contact_inbox(additional_attributes)
@contact = @contact_inbox.contact
end
def set_token
payload = { source_id: @contact_inbox.source_id, inbox_id: @web_widget.inbox.id }
@token = ::Widget::TokenService.new(payload: payload).generate_token
end
def additional_attributes
if @web_widget.inbox.account.feature_enabled?('ip_lookup')
{ created_at_ip: request.remote_ip }

View File

@@ -3,6 +3,7 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
before_action :validate_hmac, only: [:set_user]
before_action :validate_hmac_for_identified_update, only: [:update]
before_action :ensure_contact, only: [:update, :set_user, :destroy_custom_attributes]
def show; end

View File

@@ -1,5 +1,6 @@
class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
include Events::Types
before_action :ensure_contact, only: [:create]
before_action :render_not_found_if_empty, only: [:toggle_typing, :toggle_status, :set_custom_attributes, :destroy_custom_attributes]
def index

View File

@@ -3,10 +3,4 @@ class Api::V1::Widget::DirectUploadsController < ActiveStorage::DirectUploadsCon
include WebsiteTokenHelper
before_action :set_web_widget
before_action :set_contact
def create
return if @contact.nil? || @current_account.nil?
super
end
end

View File

@@ -1,5 +1,8 @@
class Api::V1::Widget::EventsController < Api::V1::Widget::BaseController
include Events::Types
# Both events reach listeners that need the contact inbox: campaigns start a conversation on it,
# and webwidget_triggered webhooks/agent bots receive it.
before_action :ensure_contact
def create
Rails.configuration.dispatcher.dispatch(permitted_params[:name], Time.zone.now, contact_inbox: @contact_inbox,

View File

@@ -1,6 +1,6 @@
class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
before_action :prevent_reply_to_resolved_conversation, only: [:create]
before_action :set_conversation, only: [:create]
before_action :ensure_contact, :set_conversation, only: [:create]
before_action :set_message, only: [:update]
def index

View File

@@ -5,13 +5,6 @@
# Inherits from ActionController::API to avoid both issues entirely.
# Rate-limited by Rack::Attack (IP + email) and gated by hCaptcha.
class Auth::ResendConfirmationsController < ActionController::API
SHOPIFY_INSTALL_REDIRECT_PATTERN = %r{\Asettings/integrations/shopify\?shopify_pending_install=[0-9a-f]{32}\z}
SHOPIFY_BILLING_PARAMETERS = {
'shop' => Shopify::ShopDomain::FORMAT,
'plan_handle' => /\A[a-zA-Z0-9_-]+\z/
}.freeze
def create
return head(:ok) unless ChatwootCaptcha.new(params[:h_captcha_client_response]).valid?
@@ -19,36 +12,7 @@ class Auth::ResendConfirmationsController < ActionController::API
return head(:ok) unless email.is_a?(String)
user = User.from_email(email.strip.downcase)
send_confirmation_instructions(user) if user && !user.confirmed?
user&.send_confirmation_instructions unless user&.confirmed?
head :ok
end
private
def confirmation_redirect_url
redirect_url = params[:redirect_url].to_s
redirect_url if redirect_url.match?(SHOPIFY_INSTALL_REDIRECT_PATTERN) || shopify_billing_redirect?(redirect_url)
end
def shopify_billing_redirect?(redirect_url)
path, query = redirect_url.split('?', 2)
return false unless path == 'settings/billing' && query.present?
valid_shopify_billing_parameters?(URI.decode_www_form(query))
rescue ArgumentError
false
end
def valid_shopify_billing_parameters?(pairs)
return false if pairs.empty?
return false unless pairs.map(&:first).uniq.size == pairs.size
pairs.all? { |key, value| SHOPIFY_BILLING_PARAMETERS[key]&.match?(value) }
end
def send_confirmation_instructions(user)
return user.send_confirmation_instructions unless confirmation_redirect_url
user.send_confirmation_instructions_with_redirect(redirect_url: confirmation_redirect_url)
end
end

View File

@@ -1,13 +0,0 @@
module ConversationAssignmentTargetsConcern
private
def validate_assignment_targets
assignee_id = params[:assignee_id]
return render_could_not_create_error('Invalid assignee_id') if assignee_id.present? && !Current.account.users.exists?(id: assignee_id)
team_id = params[:team_id]
return if team_id.blank? || Current.account.teams.exists?(id: team_id)
render_could_not_create_error('Invalid team_id')
end
end

View File

@@ -1,22 +0,0 @@
module ImpersonationLogging
def destroy
token_entry = impersonation_token_entry
return super unless token_entry
super do |user|
ImpersonationLogger.log('ended', user: user, super_admin_id: token_entry[:impersonated_by], ip: request.remote_ip)
yield user if block_given?
end
end
private
def impersonation_token_entry
token_entry = (@resource&.tokens&.dig(@token&.client) || {}).with_indifferent_access
token_entry if token_entry[:impersonation]
end
def log_impersonation_started
ImpersonationLogger.log('started', user: @resource, super_admin_id: @impersonator_id, ip: request.remote_ip)
end
end

View File

@@ -1,22 +1,41 @@
module WebsiteTokenHelper
def auth_token_params
@auth_token_params ||= ::Widget::TokenService.new(token: request.headers['X-Auth-Token']).decode_token
@auth_token_params ||= begin
decoded = ::Widget::TokenService.new(token: request.headers['X-Auth-Token']).decode_token
# A token only identifies a session in the inbox it was minted for.
decoded[:inbox_id] == @web_widget.inbox.id ? decoded : {}
end
end
def set_web_widget
@web_widget = ::Channel::WebWidget.find_by!(website_token: permitted_params[:website_token])
@current_account = @web_widget.inbox.account
render json: { error: 'Account is suspended' }, status: :unauthorized unless @current_account.active?
end
def set_contact
@contact_inbox = @web_widget.inbox.contact_inboxes.find_by(
source_id: auth_token_params[:source_id]
)
@contact = @contact_inbox&.contact
raise ActiveRecord::RecordNotFound unless @contact
raise ActiveRecord::RecordNotFound if auth_token_params[:source_id].blank?
@contact_inbox = @web_widget.inbox.contact_inboxes.find_by(source_id: auth_token_params[:source_id])
@contact = @contact_inbox&.contact
# A row whose contact is gone (dependent: :destroy_async) is not a session; the next page load issues a new one.
raise ActiveRecord::RecordNotFound if @contact_inbox && @contact.nil?
Current.contact = @contact
end
# A visitor only gets a contact once they do something worth keeping (open the widget, send a
# message, identify themselves); loading the page is not that. The token carries the ids to use,
# and the row is only ever created on the stream the widget is already subscribed to.
def ensure_contact
return if @contact.present?
raise ActiveRecord::RecordNotFound if auth_token_params[:pubsub_token].blank?
additional_attributes = @current_account.feature_enabled?('ip_lookup') ? { created_at_ip: request.remote_ip } : {}
@contact_inbox = @web_widget.create_contact_inbox(
source_id: auth_token_params[:source_id], pubsub_token: auth_token_params[:pubsub_token], additional_attributes: additional_attributes
)
@contact = @contact_inbox.contact
Current.contact = @contact
end

View File

@@ -27,8 +27,6 @@ class DashboardController < ActionController::Base
DISABLE_META_MESSAGE_SENDING
DEPLOYMENT_ENV
INSTALLATION_PRICING_PLAN
CAPTAIN_TOOLS_MANIFEST_ENABLED
CAPTAIN_TOOLS_CATALOG_URL
].freeze
before_action :set_application_pack
@@ -85,25 +83,17 @@ class DashboardController < ActionController::Base
WHATSAPP_APP_ID: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''),
IS_ENTERPRISE: ChatwootApp.enterprise?,
IS_COMPANY_ENRICHMENT_ENABLED: ChatwootApp.enterprise? && GlobalConfigService.load('CONTEXT_DEV_API_KEY', nil).present?,
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
GIT_SHA: GIT_HASH,
ALLOWED_LOGIN_METHODS: allowed_login_methods,
ACTIVE_PLATFORM_BANNERS: active_platform_banners,
ACTIVE_FEATURE_ANNOUNCEMENTS: active_feature_announcements
ACTIVE_PLATFORM_BANNERS: active_platform_banners
}
end
def active_platform_banners
return [] unless ChatwootApp.chatwoot_cloud?
PlatformBanner.active.not_feature_announcement.order(created_at: :desc).as_json(only: %i[id banner_message banner_type updated_at])
end
def active_feature_announcements
return [] unless ChatwootApp.chatwoot_cloud?
PlatformBanner.active.feature_announcement.order(created_at: :desc).as_json(only: %i[id title banner_message video_url updated_at])
PlatformBanner.active.order(created_at: :desc).as_json(only: %i[id banner_message banner_type updated_at])
end
def allowed_login_methods

View File

@@ -1,17 +1,15 @@
class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCallbacksController
include EmailHelper
SHOPIFY_INSTALL_REDIRECT_PATTERN = %r{\Asettings/integrations/shopify\?shopify_pending_install=([0-9a-f]{32})\z}
def omniauth_success
get_resource_from_auth_hash
@resource.present? ? sign_in_user(redirect_url: oauth_redirect_url, sso_account_id: oauth_context['sso_account_id']) : sign_up_user
@resource.present? ? sign_in_user : sign_up_user
end
private
def sign_in_user(redirect_url: nil, sso_account_id: nil)
def sign_in_user
# Capture before skip_confirmation! sets confirmed_at, which would
# make oauth_user_needs_password_reset? return false and skip the
# password reset for persisted unconfirmed users.
@@ -23,12 +21,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
# we can just send them to the login page again with the SSO params
# that will log them in
encoded_email = ERB::Util.url_encode(@resource.email)
redirect_to login_page_url(
email: encoded_email,
sso_auth_token: @resource.generate_sso_auth_token,
redirect_url: redirect_url,
sso_account_id: sso_account_id
)
redirect_to login_page_url(email: encoded_email, sso_auth_token: @resource.generate_sso_auth_token)
end
def sign_in_user_on_mobile
@@ -49,25 +42,18 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
def sign_up_user
return redirect_to login_page_url(error: 'no-account-found') unless account_signup_allowed?
unless validate_signup_email_is_business_domain?
return redirect_to login_page_url(
error: 'business-account-only', redirect_url: oauth_redirect_url, sso_account_id: oauth_context['sso_account_id']
)
end
return redirect_to login_page_url(error: 'business-account-only') unless validate_signup_email_is_business_domain?
create_account_for_user
set_random_password_if_oauth_user
token = @resource.send(:set_reset_password_token)
frontend_url = ENV.fetch('FRONTEND_URL', nil)
redirect_to "#{frontend_url}/app/auth/password/edit?config=default&reset_password_token=#{token}"
rescue Shopify::PendingInstallation::Error
redirect_to login_page_url(error: 'shopify-installation-failed')
end
def login_page_url(error: nil, email: nil, sso_auth_token: nil, redirect_url: nil, sso_account_id: nil)
def login_page_url(error: nil, email: nil, sso_auth_token: nil)
frontend_url = omniauth_frontend_url
params = { email: email, sso_auth_token: sso_auth_token, redirect_url: redirect_url, sso_account_id: sso_account_id }.compact
params = { email: email, sso_auth_token: sso_auth_token }.compact
params[:error] = error if error.present?
"#{frontend_url}/app/login?#{params.to_query}"
@@ -77,45 +63,8 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
ENV.fetch('FRONTEND_URL', nil)
end
def oauth_redirect_url
oauth_context['redirect_url']
end
def oauth_context
@oauth_context ||= parse_google_oauth_context(params[:state].to_s)
end
def get_redirect_route(devise_mapping)
route = super
return route unless params[:provider] == 'google_oauth2' && oauth_context.present?
# Keep the continuation on this callback's redirect instead of sharing it across tabs in the session.
"#{route}?#{URI.encode_www_form(state: oauth_context.to_json)}"
end
def parse_google_oauth_context(state)
context = state.start_with?('{') ? JSON.parse(state) : { 'redirect_url' => state }
redirect_url = context['redirect_url']
context.delete('redirect_url') unless redirect_url.is_a?(String) && allowed_google_oauth_redirect?(redirect_url)
account_id = context['sso_account_id']
context.delete('sso_account_id') unless account_id.is_a?(String) && account_id.match?(/\A[1-9]\d*\z/)
context.slice('redirect_url', 'sso_account_id')
rescue JSON::ParserError
{}
end
def allowed_google_oauth_redirect?(redirect_url)
redirect_url == 'settings/integrations/shopify' || redirect_url.match?(SHOPIFY_INSTALL_REDIRECT_PATTERN)
end
def account_signup_allowed?
return true if GlobalConfigService.account_signup_enabled?
Shopify::FeatureGate.enabled? && Shopify::PendingInstallation.pending?(token: shopify_pending_install_token)
end
def shopify_pending_install_token
@shopify_pending_install_token ||= oauth_redirect_url&.match(SHOPIFY_INSTALL_REDIRECT_PATTERN)&.captures&.first
GlobalConfigService.account_signup_enabled?
end
def resource_class(_mapping = nil)
@@ -135,18 +84,13 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
end
def create_account_for_user
attributes = {
@resource, @account = AccountBuilder.new(
account_name: extract_domain_without_tld(auth_hash['info']['email']),
user_full_name: auth_hash['info']['name'],
email: auth_hash['info']['email'],
locale: I18n.locale,
confirmed: auth_hash['info']['email_verified']
}
pending_install_token = shopify_pending_install_token
attributes[:shopify_pending_install_token] = pending_install_token if pending_install_token
builder_class = pending_install_token.present? ? Shopify::SignupService : AccountBuilder
@resource, @account = builder_class.new(**attributes).perform
).perform
Avatar::AvatarFromUrlJob.perform_later(@resource, auth_hash['info']['image'])
end

View File

@@ -6,12 +6,8 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController
skip_before_action :authenticate_user!, raise: false
def create
unless params[:redirect_url].nil? || params[:redirect_url].is_a?(String)
return render json: { error: 'Invalid redirect_url' }, status: :unprocessable_entity
end
@user = User.from_email(params[:email])
@user&.send_reset_password_instructions(redirect_url: params[:redirect_url], sso_account_id: params.permit(:sso_account_id)[:sso_account_id])
@user&.send_reset_password_instructions
build_response(I18n.t('messages.reset_password'), 200)
end

View File

@@ -1,7 +1,6 @@
class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
include MfaAuthenticationHelper
include DeviceVerificationGuard
include ImpersonationLogging
# Prevent session parameter from being passed
# Unpermitted parameter: session
@@ -67,10 +66,8 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
def handle_sso_authentication
return if !@impersonation && enforce_session_limit_for_password_login(@resource)
return render_create_error_bad_credentials if @impersonation && !@resource.consume_sso_auth_token(params[:sso_auth_token])
authenticate_resource_with_sso_token
log_impersonation_started if @impersonation
yield @resource if block_given?
render_create_success
end
@@ -85,8 +82,7 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
# DTA evicts the earliest-expiring token after save when at max_number_of_devices.
# The short-lived impersonation token would always be that one, so pre-evict to make room.
make_room_for_impersonation_token if @impersonation
token_extras = @impersonation ? { impersonation: true, impersonated_by: @impersonator_id }.compact : {}
@token = @resource.create_token(lifespan: @impersonation ? 2.days.to_i : nil, **token_extras)
@token = @resource.create_token(lifespan: @impersonation ? 2.days.to_i : nil)
@resource.save!
sign_in(:user, @resource, store: false, bypass: false)
@@ -108,7 +104,6 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
return unless user&.valid_sso_auth_token?(params[:sso_auth_token])
@resource = user
@impersonator_id = user.sso_auth_token_impersonator_id(params[:sso_auth_token])
@impersonation = user.sso_auth_token_impersonation?(params[:sso_auth_token])
end

View File

@@ -2,157 +2,45 @@ class Shopify::CallbacksController < ApplicationController
include Shopify::IntegrationHelper
def show
@account_id = verified_account_id
raise StandardError, 'Shopify authorization was denied' if params[:error].present?
verify_account!
if chatwoot_initiated?
handle_chatwoot_initiated_flow
elsif params[:code].blank?
handle_shopify_initiated_without_code
else
handle_shopify_initiated_flow
end
@response = oauth_client.auth_code.get_token(
params[:code],
redirect_uri: '/shopify/callback'
)
handle_response
rescue StandardError => e
Rails.logger.error("Shopify callback error: #{e.message}")
redirect_to error_redirect_url, allow_other_host: true
redirect_to "#{redirect_uri}?error=true"
end
private
def chatwoot_initiated? = verified_account_id.present?
def handle_chatwoot_initiated_flow
def verify_account!
@account_id = verify_shopify_token(params[:state])
raise StandardError, 'Invalid state parameter' if account.blank?
ensure_shopify_enabled!(account: account)
raise StandardError, 'Invalid HMAC signature' unless valid_hmac?
raise StandardError, 'Invalid shop domain' unless valid_shop_domain?
@shopify_installation_generation = Shopify::InstallationGeneration.current(account)
exchange_and_create_hook
redirect_to shopify_integration_url, allow_other_host: true
end
def exchange_and_create_hook
shop_generation = Shopify::PendingInstallation.generation(shop: params[:shop])
exchange_access_token
with_current_shop_generation(shop_generation) do
callback_service.connect(account: account, generation: @shopify_installation_generation, credentials: parsed_body)
end
end
def with_current_shop_generation(expected_generation)
ActiveRecord::Base.transaction do
unless expected_generation.to_i == Shopify::PendingInstallation.generation(shop: params[:shop])
raise StandardError, 'Shopify installation changed during authorization'
end
yield
end
end
def handle_shopify_initiated_flow
prepare_shopify_initiated_flow
verify_shopify_oauth_state!
load_existing_shopify_account
# Security: HMAC validation ensures params (including shop) haven't been tampered with.
# Additionally, the OAuth code is cryptographically bound to the shop that issued it.
# Shopify will reject any attempt to exchange a code at a different shop's endpoint.
exchange_access_token
if @account
with_current_shop_generation(@pending_installation_generation) do
callback_service.reconnect(account: account, generation: @shopify_installation_generation, credentials: parsed_body)
end
return redirect_to existing_account_redirect_url, allow_other_host: true
def handle_response
account.with_lock do
hook = account.hooks.find_or_initialize_by(app_id: 'shopify', reference_id: Shopify::ShopDomain.normalize(params[:shop]))
hook.update!(
access_token: parsed_body['access_token'],
status: 'enabled',
settings: {
scope: parsed_body['scope'],
connected_at: Time.current.utc.iso8601(6),
installation_id: SecureRandom.uuid
}
)
end
token_key = Shopify::PendingInstallation.create(
access_token: parsed_body['access_token'],
shop: params[:shop],
scope: parsed_body['scope'],
shop_generation: @pending_installation_generation
)
redirect_to "#{frontend_url}/app/auth/signup?shopify_pending_install=#{CGI.escape(token_key)}", allow_other_host: true
end
def handle_shopify_initiated_without_code
prepare_shopify_initiated_flow
load_existing_shopify_account
hook = @account&.hooks&.find_by(app_id: 'shopify')
return redirect_to existing_account_redirect_url, allow_other_host: true if callback_service.reusable_hook?(hook)
state = SecureRandom.hex(16)
Redis::SecureStorage.set(
oauth_state_key(state),
{
shop: params[:shop],
pending_installation_generation: Shopify::PendingInstallation.generation(shop: params[:shop])
},
10.minutes
)
authorization_url = oauth_client.auth_code.authorize_url(
redirect_uri: redirect_callback_uri,
scope: REQUIRED_SCOPES.join(','),
state: state
)
redirect_to authorization_url, allow_other_host: true
end
def prepare_shopify_initiated_flow
ensure_shopify_enabled!
raise StandardError, 'Invalid HMAC signature' unless valid_hmac?
raise StandardError, 'Invalid shop domain' unless valid_shop_domain?
end
def verify_shopify_oauth_state!
state = params[:state].to_s
raise StandardError, 'Invalid state parameter' unless state.match?(/\A[0-9a-f]{32}\z/)
stored_state = JSON.parse(Redis::SecureStorage.get(oauth_state_key(state)).to_s)
raise StandardError, 'Invalid state parameter' unless stored_state['shop'] == params[:shop]
@pending_installation_generation = stored_state['pending_installation_generation']
Redis::SecureStorage.delete(oauth_state_key(state))
return if @pending_installation_generation.to_i == Shopify::PendingInstallation.generation(shop: params[:shop])
raise StandardError, 'Shopify installation changed during authorization'
rescue JSON::ParserError
raise StandardError, 'Invalid state parameter'
end
def exchange_access_token
@response = oauth_client.auth_code.get_token(params[:code], redirect_uri: redirect_callback_uri)
end
def load_existing_shopify_account
@account = callback_service.existing_account
return unless @account
@account_id = account.id
ensure_shopify_enabled!(account: account)
@shopify_installation_generation = Shopify::InstallationGeneration.current(account)
end
def existing_account_redirect_url
billing_identity = account.internal_attributes.stringify_keys
return shopify_billing_url if billing_identity['billing_provider'] == 'shopify' && billing_identity['signup_source'] == 'shopify'
shopify_integration_url
redirect_to shopify_integration_url
end
def parsed_body
@parsed_body ||= begin
parsed = @response.response.parsed
# Handle both SnakyHash (production) and regular Hash (tests)
{
'access_token' => parsed.respond_to?(:access_token) ? parsed.access_token : parsed['access_token'],
'scope' => parsed.respond_to?(:scope) ? parsed.scope : parsed['scope']
}
end
@parsed_body ||= @response.response.parsed
end
def oauth_client
@@ -167,63 +55,21 @@ class Shopify::CallbacksController < ApplicationController
)
end
def callback_service = (@callback_service ||= Shopify::CallbackService.new(shop: params[:shop]))
def account = (@account ||= Account.find(@account_id))
def verified_account_id
return unless params[:state].to_s.count('.') == 2
@verified_account_id ||= verify_shopify_token(params[:state])
def account
@account ||= Account.find(@account_id)
end
def ensure_shopify_enabled!(account: nil)
raise StandardError, 'Shopify integration is disabled' unless Shopify::FeatureGate.enabled?(account: account)
def account_id
@account_id ||= params[:state].split('_').first
end
def redirect_callback_uri = "#{frontend_url}/shopify/callback"
def shopify_integration_url = "#{frontend_url}/app/accounts/#{account.id}/settings/integrations/shopify"
def shopify_billing_url = "#{frontend_url}/app/accounts/#{account.id}/settings/billing?shop=#{CGI.escape(params[:shop])}"
def error_redirect_url
if @account_id
begin
"#{shopify_integration_url}?error=true"
rescue ActiveRecord::RecordNotFound
"#{frontend_url}?error=true"
end
else
"#{frontend_url}?error=true"
end
def shopify_integration_url
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/shopify"
end
def frontend_url = ENV.fetch('FRONTEND_URL', '')
def redirect_uri
return shopify_integration_url if account
def oauth_state_key(state)
"shopify_oauth_state:#{state}"
end
def valid_shop_domain?
return false if params[:shop].blank?
# Shopify shop domains must match: *.myshopify.com or *.myshopify.io (for dev shops)
params[:shop].match?(/\A[a-zA-Z0-9][a-zA-Z0-9\-]*\.myshopify\.(com|io)\z/)
end
def valid_hmac?
hmac = params[:hmac].to_s
return false unless hmac.match?(/\A[0-9a-f]{64}\z/)
# Shopify HMAC validation
# Reference: https://shopify.dev/docs/apps/build/authentication-authorization/get-access-tokens
query_params = request.query_parameters.except('hmac')
query_string = URI.encode_www_form(query_params.sort)
# Compute HMAC-SHA256
computed_hmac = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('SHA256'), client_secret, query_string)
ActiveSupport::SecurityUtils.secure_compare(computed_hmac, hmac)
ENV.fetch('FRONTEND_URL', nil)
end
end

View File

@@ -1,71 +0,0 @@
class Stripe::CallbacksController < ApplicationController
OAUTH_ERROR_CODES = %w[invalid_request invalid_client invalid_grant unauthorized_client unsupported_grant_type
invalid_scope access_denied server_error temporarily_unavailable].freeze
before_action :verify_browser_state
before_action :load_installation
rescue_from Faraday::TimeoutError, Faraday::ConnectionFailed, with: -> { authorization_failed('token_exchange_unavailable') }
def show
return authorization_failed('provider_error') if params[:error].present?
return authorization_failed('missing_code') if params[:code].blank?
token = Integrations::Stripe::Oauth.client.auth_code.get_token(params[:code], redirect_uri: Integrations::Stripe::Oauth.callback_url)
return authorization_failed('token_mode_mismatch') unless token.params.fetch('livemode') == @livemode
save_connection(token)
redirect_to destination
rescue OAuth2::Error => e
authorization_failed('token_exchange_failed', oauth_error: safe_oauth_error_code(e.code), http_status: e.response.status)
end
private
def safe_oauth_error_code(code)
# Provider messages and response bodies can contain credentials. Only log known error codes.
OAUTH_ERROR_CODES.include?(code) ? code : 'unknown'
end
def authorization_failed(reason, **details)
Rails.logger.warn({ event: 'stripe_oauth_authorization_failed', reason: reason, account_id: @account.id,
request_id: request.request_id, expected_livemode: @livemode }.merge(details).to_json)
redirect_to "#{destination}?error=authorization_failed"
end
def save_connection(token)
@account.with_lock do
hook = @account.hooks.find_or_initialize_by(app_id: 'stripe')
hook.reference_id = token.params.fetch('stripe_user_id')
hook.status = :enabled
hook.settings = hook.settings.merge('livemode' => @livemode, 'connected_at' => Time.current.iso8601)
Integrations::Stripe::Connection.new(hook).store_token!(token)
hook.reauthorized!
end
end
def verify_browser_state
browser_state = cookies.signed[:stripe_oauth_state]
return head :bad_request unless browser_state.present? && browser_state == params[:state]
cookies.delete(:stripe_oauth_state)
end
def load_installation
return head :not_found unless Integrations::Stripe::Oauth.configured?
state = Integrations::Stripe::Oauth.consume_state(params[:state])
return head :bad_request unless state
@account = Account.find(state.fetch('account_id'))
return head :not_found unless @account.feature_enabled?('stripe_integration')
@livemode = state.fetch('livemode')
return head :bad_request unless @livemode == Integrations::Stripe::Oauth.livemode?
head :forbidden unless @account.account_users.exists?(user_id: state.fetch('user_id'), role: :administrator)
end
def destination
"#{ENV.fetch('FRONTEND_URL')}/app/accounts/#{@account.id}/settings/integrations/stripe"
end
end

View File

@@ -29,7 +29,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
end
def create
errors = shopify_partner_config_errors + stripe_config_errors
errors = shopify_partner_config_errors
params['app_config'].each do |key, value|
break if errors.any?
next unless @allowed_configs.include?(key)
@@ -39,19 +39,15 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
errors.concat(i.errors.full_messages) unless i.save
end
redirect_to super_admin_app_config_path(config: @config), flash: errors.any? ? { alert: errors.join(', ') } : success_flash
if errors.any?
redirect_to super_admin_app_config_path(config: @config), alert: errors.join(', ')
else
redirect_to super_admin_settings_path, flash: success_flash
end
end
private
def stripe_config_errors
return [] unless @config == 'stripe'
values = InstallationConfig.where(name: @allowed_configs).to_h { |config| [config.name, config.value] }
values.merge!(params.fetch('app_config', {}).permit(*@allowed_configs).to_h)
Integrations::Stripe::Oauth.configuration_errors(url: values['STRIPE_APP_AUTHORIZE_URL'], key: values['STRIPE_APP_SECRET_KEY'])
end
def set_config
@config = params[:config] || 'general'
end
@@ -65,7 +61,6 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
'microsoft' => %w[AZURE_APP_ID AZURE_APP_SECRET],
'email' => %w[MAILER_INBOUND_EMAIL_DOMAIN ACCOUNT_EMAILS_LIMIT ACCOUNT_EMAILS_PLAN_LIMITS],
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
'stripe' => %w[STRIPE_APP_AUTHORIZE_URL STRIPE_APP_SECRET_KEY],
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET SLACK_SIGNING_SECRET],
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
'tiktok' => %w[TIKTOK_APP_ID TIKTOK_APP_SECRET TIKTOK_API_VERSION],

View File

@@ -7,9 +7,9 @@
class SuperAdmin::ApplicationController < Administrate::ApplicationController
include ActionView::Helpers::TagHelper
include ActionView::Context
include SuperAdmin::NavigationHelper
helper SuperAdmin::NavigationHelper, SuperAdmin::CommandBarHelper, SuperAdmin::UiHelper
helper_method :render_vue_component
helper_method :render_vue_component, :settings_open?, :settings_pages
# authenticiation done via devise : SuperAdmin Model
before_action :authenticate_super_admin!

View File

@@ -67,19 +67,6 @@ class SuperAdmin::UsersController < SuperAdmin::ApplicationController
end
end
def impersonate
redirect_to impersonation_url('open'), status: :see_other, allow_other_host: true
end
def impersonation_link
render json: { url: impersonation_url('copy') }
end
def impersonation_url(via)
ImpersonationLogger.log('link_created', user: requested_resource, super_admin_id: current_super_admin.id, ip: request.remote_ip, via: via)
requested_resource.generate_sso_link_with_impersonation(current_super_admin)
end
def check_email_suppression
user = requested_resource
redirect_with_suppression_result(user, Email::SesSuppressionService.new.lookup(user.email))

View File

@@ -5,7 +5,6 @@ class Webhooks::ShopifyController < ActionController::API
before_action :ensure_shopify_enabled, unless: :mandatory_cleanup_event?
before_action :verify_hmac!
around_action :with_installation_transaction, only: :events
def events
case request.headers['X-Shopify-Topic']
@@ -20,10 +19,6 @@ class Webhooks::ShopifyController < ActionController::API
private
def with_installation_transaction(&)
ActiveRecord::Base.transaction(&)
end
def ensure_shopify_enabled
head :not_found unless Shopify::FeatureGate.enabled?
end

View File

@@ -8,8 +8,9 @@ class WidgetsController < ActionController::Base
before_action :skip_crawler_requests
before_action :ensure_location_is_supported
before_action :set_token
before_action :set_contact
before_action :build_contact
before_action :set_contact_inbox
before_action :ensure_contact_inbox
before_action :set_visitor_session
after_action :allow_iframe_requests
private
@@ -33,30 +34,34 @@ class WidgetsController < ActionController::Base
end
def set_token
@token = permitted_params[:cw_conversation]
@auth_token_params = if @token.present?
::Widget::TokenService.new(token: @token).decode_token
else
{}
end
@auth_token_params = ::Widget::TokenService.new(token: permitted_params[:cw_conversation]).decode_token
# A token only identifies a session in the inbox it was minted for.
@auth_token_params = {} unless @auth_token_params[:inbox_id] == @web_widget.inbox.id
end
def set_contact
def set_contact_inbox
return if @auth_token_params[:source_id].nil?
@contact_inbox = ::ContactInbox.find_by(
inbox_id: @web_widget.inbox.id,
source_id: @auth_token_params[:source_id]
)
@contact_inbox = ::ContactInbox.find_by(inbox_id: @web_widget.inbox.id, source_id: @auth_token_params[:source_id])
return unless @contact_inbox && @contact_inbox.contact.nil?
@contact = @contact_inbox&.contact
# A row whose contact is gone (dependent: :destroy_async) is not a session to continue.
@contact_inbox = nil
@auth_token_params = {}
end
def build_contact
return if @contact.present?
# A token issued by a server that no longer creates the contact on load carries the ids to reuse,
# so the row lands on the stream the visitor is already subscribed to.
def ensure_contact_inbox
return if @contact_inbox.present?
@contact_inbox, @token = build_contact_inbox_with_token(@web_widget, additional_attributes)
@contact = @contact_inbox.contact
@contact_inbox = @web_widget.create_contact_inbox(
source_id: @auth_token_params[:source_id], pubsub_token: @auth_token_params[:pubsub_token], additional_attributes: additional_attributes
)
end
def set_visitor_session
@token, @pubsub_token = visitor_session(@web_widget, permitted_params[:cw_conversation], @auth_token_params, @contact_inbox)
end
def ensure_account_is_active

View File

@@ -3,25 +3,16 @@ require 'administrate/base_dashboard'
class PlatformBannerDashboard < Administrate::BaseDashboard
ATTRIBUTE_TYPES = {
id: Field::Number,
banner_type: Field::Select.with_options(collection: %w[info warning error feature_announcement]),
title: Field::String,
banner_message: Field::Text.with_options(truncate: 200),
video_url: Field::String,
banner_type: Field::Select.with_options(collection: %w[info warning error]),
active: Field::Boolean,
created_at: Field::DateTime,
updated_at: Field::DateTime
}.freeze
COLLECTION_ATTRIBUTES = %i[id banner_type title banner_message active created_at].freeze
SHOW_PAGE_ATTRIBUTES = %i[id banner_type title banner_message video_url active created_at updated_at].freeze
FORM_ATTRIBUTES = %i[banner_type title banner_message video_url active].freeze
COLLECTION_FILTERS = {
info: ->(resources) { resources.where(banner_type: :info) },
warning: ->(resources) { resources.where(banner_type: :warning) },
error: ->(resources) { resources.where(banner_type: :error) },
feature_announcement: ->(resources) { resources.where(banner_type: :feature_announcement) }
}.freeze
COLLECTION_ATTRIBUTES = %i[id banner_message banner_type active created_at].freeze
SHOW_PAGE_ATTRIBUTES = %i[id banner_message banner_type active created_at updated_at].freeze
FORM_ATTRIBUTES = %i[banner_message banner_type active].freeze
def display_resource(platform_banner)
"Banner ##{platform_banner.id} (#{platform_banner.banner_type})"

View File

@@ -36,7 +36,8 @@ class UserDashboard < Administrate::BaseDashboard
updated_at: Field::DateTime,
pubsub_token: Field::String,
type: Field::Select.with_options(collection: [nil, 'SuperAdmin']),
accounts: CountField
accounts: CountField,
access_token: Field::HasOne
}.freeze
# COLLECTION_ATTRIBUTES
@@ -67,6 +68,7 @@ class UserDashboard < Administrate::BaseDashboard
updated_at
confirmed_at
account_users
access_token
].freeze
# FORM_ATTRIBUTES

View File

@@ -17,8 +17,7 @@ module Shopify::IntegrationHelper
def token_payload(account_id)
{
sub: account_id,
iat: Time.current.to_i,
exp: 10.minutes.from_now.to_i
iat: Time.current.to_i
}
end

View File

@@ -1,70 +0,0 @@
# Icon for each account feature flag in config/features.yml, shown on the account's Features tab.
# Tailwind scans this file: every class must be a literal here.
advanced_assignment: 'i-lucide-gauge'
advanced_search: 'i-lucide-scan-search'
advanced_search_indexing: 'i-lucide-database-zap'
agent_bots: 'i-lucide-bot'
agent_management: 'i-lucide-square-user'
api_and_webhooks: 'i-lucide-webhook'
assignment_v2: 'i-lucide-user-cog'
audit_log_ip_address: 'i-lucide-map-pinned'
audit_logs: 'i-lucide-scroll-text'
auto_resolve_conversations: 'i-lucide-timer-reset'
automations: 'i-lucide-repeat'
branded_email_templates: 'i-lucide-palette'
campaign_analytics: 'i-lucide-chart-no-axes-combined'
campaigns: 'i-lucide-megaphone'
canned_responses: 'i-lucide-message-square-quote'
captain_classifier: 'i-lucide-brain-circuit'
captain_document_auto_sync: 'i-lucide-refresh-cw'
captain_integration: 'i-woot-captain'
captain_tasks: 'i-lucide-list-checks'
channel_email: 'i-lucide-mail'
channel_facebook: 'i-logos-messenger'
channel_instagram: 'i-logos-instagram-icon dark:invert'
channel_tiktok: 'i-logos-tiktok-icon dark:invert'
channel_voice: 'i-lucide-phone-call'
channel_website: 'i-lucide-message-circle'
chatwoot_v4: 'i-lucide-rocket'
companies: 'i-lucide-building-2'
company_enrichment: 'i-lucide-sparkles'
contact_chatwoot_support_team: 'i-lucide-life-buoy'
conversation_monitors: 'i-lucide-activity'
conversation_required_attributes: 'i-lucide-clipboard-list'
conversation_unread_counts: 'i-lucide-bell-dot'
crm: 'i-lucide-contact'
crm_integration: 'i-lucide-link'
crm_v2: 'i-lucide-book-user'
csat_review_notes: 'i-lucide-star'
custom_attributes: 'i-lucide-code'
custom_reply_domain: 'i-lucide-globe'
custom_reply_email: 'i-lucide-at-sign'
custom_roles: 'i-lucide-shield-plus'
custom_tools: 'i-lucide-wrench'
data_import: 'i-lucide-upload'
delayed_automations: 'i-lucide-hourglass'
disable_branding: 'i-lucide-eye-off'
email_continuity_on_api_channel: 'i-lucide-reply'
help_center: 'i-lucide-library-big'
help_center_embedding_search: 'i-lucide-text-search'
inbound_emails: 'i-lucide-mail-open'
inbox_management: 'i-lucide-inbox'
inbox_view: 'i-lucide-layout-list'
integrations: 'i-lucide-blocks'
ip_lookup: 'i-lucide-map-pin'
labels: 'i-lucide-tags'
linear_integration: 'i-logos-linear-icon dark:invert'
macros: 'i-lucide-toy-brick'
notion_integration: 'i-logos-notion-icon dark:invert'
reply_mailer_migration: 'i-lucide-send'
report_rollup: 'i-lucide-chart-column-stacked'
reports: 'i-lucide-chart-spline'
saml: 'i-lucide-key-round'
search_with_gin: 'i-lucide-search'
shopify_integration: 'i-logos-shopify'
sla: 'i-lucide-clock-alert'
team_management: 'i-lucide-users'
unread_count_for_filters: 'i-lucide-list-filter'
voice_recorder: 'i-lucide-mic'
whatsapp_embedded_signup_inbox_creation: 'i-logos-whatsapp-icon'
whatsapp_manual_transfer: 'i-logos-whatsapp-icon'

View File

@@ -7,17 +7,6 @@ module SuperAdmin::AccountFeaturesHelper
account_features.filter { |feature| feature['premium'] }.pluck('name')
end
# The list the dashboard shows its "Learn more" links from (dashboard/helper/featureHelper.js).
def self.feature_help_urls
YAML.safe_load(Rails.root.join('config/feature_help_urls.yml').read).freeze
end
def self.feature_icons
icons = YAML.safe_load(Rails.root.join('app/helpers/super_admin/account_feature_icons.yml').read)
icons.default = 'i-lucide-toggle-right'
icons.freeze
end
# Returns a hash mapping feature names to their display names
def self.feature_display_names
account_features.each_with_object({}) do |feature, hash|
@@ -48,12 +37,16 @@ module SuperAdmin::AccountFeaturesHelper
filtered = filter_deprecated_features(filtered)
display_names = feature_display_names
premium_features = account_premium_features
regular, premium = filtered.partition { |key, _value| premium_features.exclude?(key) }
regular, premium = filtered.partition { |key, _value| account_premium_features.exclude?(key) }
[
sort_and_transform_features(regular, display_names),
sort_and_transform_features(premium, display_names)
]
end
def self.filtered_features(features)
regular, premium = partition_features(features)
regular.merge(premium)
end
end

View File

@@ -1,70 +0,0 @@
module SuperAdmin::CommandBarHelper
def command_groups
[
{ label: t('super_admin.command_bar.go_to'), commands: primary_nav_items },
{ label: t('super_admin.command_bar.create'), commands: create_commands },
{ label: t('super_admin.navigation.settings'), commands: settings_nav_items },
{ label: t('super_admin.navigation.monitor'), commands: monitor_nav_items },
{ label: t('super_admin.navigation.theme.label'), commands: theme_commands },
{ label: t('super_admin.navigation.account'), commands: session_commands }
]
end
def record_commands(record)
commands = [
{ action: :show, label: t('super_admin.command_bar.open'), url: [namespace, record], icon: 'i-lucide-arrow-right' },
{ action: :edit, label: t('administrate.actions.edit'), url: [:edit, namespace, record], icon: 'i-lucide-pencil' }
].select { |command| accessible_action?(record, command[:action]) && !current_page?(command[:url]) }
commands += user_commands(record) if record.is_a?(User)
commands += account_commands(record) if record.is_a?(Account)
return commands unless accessible_action?(record, :destroy)
commands << { label: t('administrate.actions.destroy'), url: [namespace, record], method: :delete, icon: 'i-lucide-trash-2', danger: true,
form: { data: { confirm: t('administrate.actions.confirm') } } }
end
private
def create_commands
nav_resources.select { |resource| existing_action?(resource, :new) }.map do |resource|
{ label: t('administrate.actions.new_resource', name: resource.singularize.titleize.downcase),
url: [:new, namespace, resource.singularize.to_sym], icon: 'i-lucide-plus' }
end
end
def theme_commands
[
{ label: t('super_admin.navigation.theme.system'), icon: 'i-lucide-monitor', data: { theme_value: 'system' } },
{ label: t('super_admin.navigation.theme.light'), icon: 'i-lucide-sun', data: { theme_value: 'light' } },
{ label: t('super_admin.navigation.theme.dark'), icon: 'i-lucide-moon', data: { theme_value: 'dark' } }
]
end
def session_commands
[
{ label: t('super_admin.navigation.profile'), url: super_admin_user_path(current_super_admin), icon: 'i-lucide-user' },
{ label: t('super_admin.navigation.agent_dashboard'), url: '/', icon: 'i-lucide-message-circle' },
{ label: t('super_admin.navigation.logout'), url: super_admin_logout_path, icon: 'i-lucide-log-out' }
]
end
def user_commands(user)
commands = []
if current_page?([namespace, user])
commands << { label: t('super_admin.users.impersonate'), icon: 'i-lucide-venetian-mask', data: { dialog_open: 'impersonate-dialog' } }
end
return commands if user.confirmed?
commands << { label: t('super_admin.users.resend_confirmation.button'), url: resend_confirmation_super_admin_user_path(user), method: :post,
icon: 'i-lucide-mail-check' }
end
def account_commands(account)
commands = [{ label: t('super_admin.accounts.reset_cache'), url: reset_cache_super_admin_account_path(account), method: :post,
icon: 'i-lucide-refresh-cw' }]
return commands unless Seeders::AccountSeeder.allowed?
commands << { label: t('super_admin.accounts.seed_data'), url: seed_super_admin_account_path(account), method: :post, icon: 'i-lucide-sprout',
danger: true, form: { data: { confirm: t('super_admin.accounts.seed_data_warning') } } }
end
end

View File

@@ -6,39 +6,39 @@ saml:
name: 'SAML SSO'
description: 'Configuration for controlling SAML Single Sign-On availability'
enabled: <%= ChatwootApp.enterprise? %>
icon: 'i-lucide-key-round'
icon: 'icon-lock-line'
config_key: 'saml'
enterprise: true
custom_branding:
name: 'Custom Branding'
description: 'Apply your own branding to this installation.'
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
icon: 'i-lucide-palette'
icon: 'icon-paint-brush-line'
config_key: 'custom_branding'
enterprise: true
agent_capacity:
name: 'Agent Capacity'
description: 'Set limits to auto-assigning conversations to your agents.'
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
icon: 'i-lucide-gauge'
icon: 'icon-hourglass-line'
enterprise: true
audit_logs:
name: 'Audit Logs'
description: 'Track and trace account activities with ease with detailed audit logs.'
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
icon: 'i-lucide-scroll-text'
icon: 'icon-menu-search-line'
enterprise: true
disable_branding:
name: 'Disable Branding'
description: 'Disable branding on live-chat widget and external emails.'
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
icon: 'i-lucide-eye-off'
icon: 'icon-sailbot-fill'
enterprise: true
voice_calls:
name: 'Voice Calls'
description: 'Enable voice calling capabilities for your agents and customers.'
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
icon: 'i-lucide-phone-call'
icon: 'icon-voice-line'
enterprise: true
# ------- Product Features ------- #
@@ -46,12 +46,12 @@ help_center:
name: 'Help Center'
description: 'Allow agents to create help center articles and publish them in a portal.'
enabled: true
icon: 'i-lucide-book-open'
icon: 'icon-book-2-line'
captain:
name: 'Captain'
description: 'Enable AI-powered conversations with your customers.'
enabled: true
icon: 'i-woot-captain text-n-violet-9'
icon: 'icon-captain'
config_key: 'captain'
# ------- Communication Channels ------- #
@@ -59,100 +59,94 @@ live_chat:
name: 'Live Chat'
description: 'Improve your customer experience using a live chat on your website.'
enabled: true
icon: 'i-lucide-message-circle'
icon: 'icon-chat-smile-3-line'
email:
name: 'Email'
description: 'Manage your email customer interactions from Chatwoot.'
enabled: true
icon: 'i-lucide-mail'
icon: 'icon-mail-send-fill'
config_key: 'email'
sms:
name: 'SMS'
description: 'Manage your SMS customer interactions from Chatwoot.'
enabled: true
icon: 'i-lucide-message-square-text'
icon: 'icon-message-line'
messenger:
name: 'Messenger'
description: 'Stay connected with your customers on Facebook & Instagram.'
enabled: true
icon: 'i-logos-messenger'
icon: 'icon-messenger-line'
config_key: 'facebook'
instagram:
name: 'Instagram'
description: 'Stay connected with your customers on Instagram'
enabled: true
icon: 'i-logos-instagram-icon dark:invert'
icon: 'icon-instagram'
config_key: 'instagram'
tiktok:
name: 'TikTok'
description: 'Stay connected with your customers on TikTok'
enabled: true
icon: 'i-logos-tiktok-icon dark:invert'
icon: 'icon-tiktok'
config_key: 'tiktok'
whatsapp:
name: 'WhatsApp'
description: 'Manage your WhatsApp business interactions from Chatwoot.'
enabled: true
icon: 'i-logos-whatsapp-icon'
icon: 'icon-whatsapp-line'
telegram:
name: 'Telegram'
description: 'Manage your Telegram customer interactions from Chatwoot.'
enabled: true
icon: 'i-logos-telegram'
icon: 'icon-telegram-line'
line:
name: 'Line'
description: 'Manage your Line customer interactions from Chatwoot.'
enabled: true
icon: 'i-ri-line-fill text-[#06C755]'
icon: 'icon-line-line'
# ------- OAuth & Authentication ------- #
google:
name: 'Google'
description: 'Configuration for setting up Google OAuth Integration'
enabled: true
icon: 'i-logos-google-icon'
icon: 'icon-google'
config_key: 'google'
microsoft:
name: 'Microsoft'
description: 'Configuration for setting up Microsoft Email'
enabled: true
icon: 'i-logos-microsoft-icon'
icon: 'icon-microsoft'
config_key: 'microsoft'
# ------- Third-party Integrations ------- #
stripe:
name: 'Stripe'
description: 'Configure the Stripe customer billing integration'
enabled: true
icon: 'icon-settings-2-line'
config_key: 'stripe'
linear:
name: 'Linear'
description: 'Configuration for setting up Linear Integration'
enabled: true
icon: 'i-logos-linear-icon dark:invert'
icon: 'icon-linear'
config_key: 'linear'
notion:
name: 'Notion'
description: 'Configuration for setting up Notion Integration'
enabled: true
icon: 'i-logos-notion-icon dark:invert'
icon: 'icon-notion'
config_key: 'notion'
slack:
name: 'Slack'
description: 'Configuration for setting up Slack Integration'
enabled: true
icon: 'i-logos-slack-icon'
icon: 'icon-slack'
config_key: 'slack'
whatsapp_embedded:
name: 'WhatsApp Embedded'
description: 'Configuration for setting up WhatsApp Embedded Integration'
enabled: true
icon: 'i-logos-whatsapp-icon'
icon: 'icon-whatsapp-line'
config_key: 'whatsapp_embedded'
shopify:
name: 'Shopify'
description: 'Configuration for setting up Shopify Integration'
enabled: true
icon: 'i-logos-shopify'
icon: 'icon-shopify'
config_key: 'shopify'

View File

@@ -8,9 +8,9 @@ module SuperAdmin::FeaturesHelper
quantity = ChatwootHub.pricing_plan_quantity
if plan == 'premium'
"You are currently on the <span class='font-semibold'>#{plan.titleize}</span> plan with <span class='font-semibold'>#{quantity} agents</span>."
"You are currently on the <span class='font-semibold'>#{plan}</span> plan with <span class='font-semibold'>#{quantity} agents</span>."
else
"You are currently on the <span class='font-semibold'>#{plan.titleize}</span> edition plan."
"You are currently on the <span class='font-semibold'>#{plan}</span> edition plan."
end
end
end

View File

@@ -1,121 +1,16 @@
module SuperAdmin::NavigationHelper
NAV_ICONS = {
'accounts' => 'i-lucide-building-2',
'users' => 'i-lucide-users',
'agent_bots' => 'i-lucide-bot',
'platform_apps' => 'i-lucide-blocks',
'platform_banners' => 'i-lucide-megaphone'
}.freeze
# Routed resources that are reached from other pages instead of the navigation.
HIDDEN_RESOURCES = %w[dashboard access_tokens installation_configs].freeze
# Always in the tab bar on small screens; on phones the others sit behind "More".
PINNED_RESOURCES = %w[accounts users].freeze
# Names short enough to sit under an icon in that tab bar.
TAB_LABELS = {
'agent_bots' => 'super_admin.navigation.tabs.agent_bots',
'platform_apps' => 'super_admin.navigation.tabs.platform_apps',
'platform_banners' => 'super_admin.navigation.tabs.platform_banners'
}.freeze
# Heading each feature's page sits under in the settings list. Pages not listed stay at the top.
SETTINGS_GROUPS = {
'super_admin.navigation.settings_groups.product' => %w[saml custom_branding captain],
'super_admin.navigation.settings_groups.channels' => %w[email messenger instagram tiktok],
'super_admin.navigation.settings_groups.authentication' => %w[google microsoft],
'super_admin.navigation.settings_groups.integrations' => %w[linear notion slack whatsapp_embedded shopify]
}.freeze
def settings_open?
params[:controller].in? %w[super_admin/settings super_admin/app_configs]
end
def monitor_open?
controller_name.in? %w[instance_statuses push_diagnostics]
end
def settings_pages
@settings_pages ||= begin
features = SuperAdmin::FeaturesHelper.available_features.select do |_feature, attrs|
attrs['config_key'].present? && attrs['enabled']
end
pages = [['general', { 'config_key' => 'general', 'name' => t('super_admin.settings.pages.general'), 'icon' => 'i-lucide-sliders-horizontal' }]]
if ChatwootApp.chatwoot_cloud?
pages << ['internal', { 'config_key' => 'internal', 'name' => t('super_admin.settings.pages.internal'),
'description' => t('super_admin.settings.pages.internal_description'), 'icon' => 'i-lucide-lock-keyhole' }]
end
pages + features.to_a
features = SuperAdmin::FeaturesHelper.available_features.select do |_feature, attrs|
attrs['config_key'].present? && attrs['enabled']
end
end
def primary_nav_items
instance = t('super_admin.navigation.groups.instance')
[
{ label: t('super_admin.navigation.dashboard'), url: super_admin_root_path, icon: 'i-lucide-layout-dashboard',
active: current_page?(super_admin_root_path), pinned: true },
*resource_nav_items,
{ label: t('super_admin.navigation.settings'), url: super_admin_settings_path, icon: 'i-lucide-settings', active: settings_open?,
pinned: true, group: instance },
{ label: t('super_admin.navigation.monitor'), url: super_admin_instance_status_path, icon: 'i-lucide-activity', active: monitor_open?,
group: instance }
]
end
# Add general at the beginning
general_feature = [['general', { 'config_key' => 'general', 'name' => 'General' }]]
def settings_nav_items(current_config = nil)
overview = { label: t('super_admin.navigation.overview'), url: super_admin_settings_path, icon: 'i-lucide-layout-grid',
active: controller_name == 'settings' }
[overview] + settings_pages.map do |feature_key, attrs|
group_key = SETTINGS_GROUPS.keys.find { |key| SETTINGS_GROUPS[key].include?(feature_key) }
{ label: attrs['name'], url: super_admin_app_config_path(config: attrs['config_key']), group: (t(group_key) if group_key),
icon: attrs['icon'], active: attrs['config_key'] == current_config }
end
end
# Settings and Monitor swap the sidebar for their own links; nil on every other page.
def section_nav
if settings_open?
current_config = params[:config] || 'general' if controller_name == 'app_configs'
{ label: t('super_admin.navigation.settings'), items: settings_nav_items(current_config) }
elsif monitor_open?
{ label: t('super_admin.navigation.monitor'), items: monitor_nav_items }
end
end
def monitor_nav_items
[
{ label: t('super_admin.navigation.instance_health'), url: super_admin_instance_status_path, icon: 'i-lucide-heart-pulse',
active: controller_name == 'instance_statuses' },
{ label: t('super_admin.navigation.push_diagnostics'), url: super_admin_push_diagnostics_path, icon: 'i-lucide-bell-ring',
active: controller_name == 'push_diagnostics' },
{ label: t('super_admin.navigation.sidekiq_dashboard'), url: sidekiq_web_path, icon: 'i-lucide-layers', external: true }
]
end
def nav_resources
Administrate::Namespace.new(namespace).resources_with_index_route.reject do |resource|
HIDDEN_RESOURCES.include?(resource) || (resource == 'platform_banners' && !ChatwootApp.chatwoot_cloud?)
end
end
# superadmin.js keeps the tab last opened on a page in this cookie, so the page renders with that tab open.
def active_tab(tabs)
path, key = cookies[:super_admin_tab].to_s.split('#', 2)
path == request.path && tabs.any? { |tab| tab[:key] == key } ? key : tabs.first[:key]
end
private
def resource_nav_items
nav_resources.map do |resource|
tab_label_key = TAB_LABELS[resource]
{ label: display_resource_name(resource), tab_label: (t(tab_label_key) if tab_label_key), url: resource_index_route(resource),
icon: NAV_ICONS[resource], active: nav_link_state(resource) == :active,
pinned: PINNED_RESOURCES.include?(resource), group: t('super_admin.navigation.groups.manage') }
end
general_feature + features.to_a
end
end

View File

@@ -1,19 +0,0 @@
# Tailwind classes for form controls. Rails builds those tags (`f.text_field`, `select_tag`, `check_box_tag` and so on),
# so the classes are passed to them; everything else is a partial. Tailwind scans this file: every class must be a literal here.
module SuperAdmin::UiHelper
INPUT = %w[
block min-h-9 w-full rounded-lg border border-n-slate-6 bg-n-solid-1 px-3 py-1.5 text-body-main text-n-slate-12 outline-none
transition-colors placeholder:text-n-slate-10 hover:border-n-slate-8 focus:border-n-brand focus:ring-2 focus:ring-n-brand/25
disabled:cursor-not-allowed disabled:bg-n-slate-3 disabled:text-n-slate-10 touch:min-h-11 touch:text-base
].join(' ').freeze
CLASSES = {
input: INPUT,
select: "#{INPUT} appearance-none bg-chevron pe-9",
checkbox: 'size-4 shrink-0 cursor-pointer accent-n-brand disabled:cursor-not-allowed touch:size-5'
}.freeze
def ui(name, extra = nil)
class_names(CLASSES.fetch(name), extra)
end
end

View File

@@ -1,9 +1,28 @@
module WidgetHelper
def build_contact_inbox_with_token(web_widget, additional_attributes = {})
contact_inbox = web_widget.create_contact_inbox(additional_attributes)
payload = { source_id: contact_inbox.source_id, inbox_id: web_widget.inbox.id }
token = ::Widget::TokenService.new(payload: payload).generate_token
# Token and pubsub_token for the visitor's session. A current token is handed back as is;
# otherwise one is minted, continuing the row's ids when there is a row and fresh ones when not.
# A token without a pubsub_token predates lazy creation: with a row it is reissued from the row,
# without one it is not a session. Nothing is written to the database until
# WebsiteTokenHelper#ensure_contact.
def visitor_session(web_widget, token, auth_token_params, contact_inbox)
session = contact_inbox ? contact_inbox.slice(:source_id, :pubsub_token) : auth_token_params
session = {} if session[:pubsub_token].blank?
return [token, session[:pubsub_token]] if session.present? && session[:pubsub_token] == auth_token_params[:pubsub_token]
[contact_inbox, token]
source_id = session[:source_id] || SecureRandom.uuid
pubsub_token = session[:pubsub_token] || ContactInbox.generate_unique_secure_token
[visitor_token(web_widget, source_id, pubsub_token), pubsub_token]
end
def build_contact_inbox_with_token(web_widget)
contact_inbox = web_widget.create_contact_inbox
[contact_inbox, visitor_token(web_widget, contact_inbox.source_id, contact_inbox.pubsub_token)]
end
private
def visitor_token(web_widget, source_id, pubsub_token)
payload = { source_id: source_id, inbox_id: web_widget.inbox.id, pubsub_token: pubsub_token }
::Widget::TokenService.new(payload: payload).generate_token
end
end

View File

@@ -8,7 +8,6 @@ import StatusBanner from './components/app/StatusBanner.vue';
import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue';
import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue';
import LowBackupCodesBanner from './components/app/LowBackupCodesBanner.vue';
import FeatureAnnouncement from './components-next/feature-announcement/FeatureAnnouncement.vue';
import vueActionCable from './helper/actionCable';
import { useRouter } from 'vue-router';
import { useStore } from 'dashboard/composables/store';
@@ -37,7 +36,6 @@ export default {
WootSnackbarBox,
PendingEmailVerificationBanner,
LowBackupCodesBanner,
FeatureAnnouncement,
},
setup() {
const router = useRouter();
@@ -151,7 +149,6 @@ export default {
<PendingEmailVerificationBanner v-if="hideOnOnboardingView" />
<PaymentPendingBanner v-if="hideOnOnboardingView" />
<LowBackupCodesBanner v-if="hideOnOnboardingView" />
<FeatureAnnouncement v-if="hideOnOnboardingView" />
</template>
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">

View File

@@ -9,10 +9,6 @@ class AutomationsAPI extends ApiClient {
clone(automationId) {
return axios.post(`${this.url}/${automationId}/clone`);
}
linkedToMonitor(monitorId, signal) {
return axios.get(this.url, { params: { monitor_id: monitorId }, signal });
}
}
export default new AutomationsAPI();

View File

@@ -66,6 +66,22 @@ class TasksAPI extends ApiClient {
);
}
/**
* Gets label suggestions for a conversation.
* @param {string} conversationId - The conversation ID.
* @param {AbortSignal} [signal] - AbortSignal to cancel the request.
* @returns {Promise} A promise that resolves with label suggestions.
*/
labelSuggestion(conversationId, signal) {
return axios.post(
`${this.url}/label_suggestion`,
{
conversation_display_id: conversationId,
},
{ signal }
);
}
/**
* Sends a follow-up message to continue refining a previous task result.
* @param {Object} options - The follow-up options.

View File

@@ -1,21 +0,0 @@
// Uses fetch instead of the axios client so the user's auth headers are never sent to the catalog host
const fetchCatalogJson = async (url, { signal } = {}) => {
const response = await fetch(url, { signal, credentials: 'omit' });
if (!response.ok) {
throw new Error(`Could not load the tools catalog: ${response.status}`);
}
return response.json();
};
export const fetchToolsCatalog = (baseUrl, options) => {
if (!baseUrl) throw new Error('The tools catalog URL is not configured');
return fetchCatalogJson(`${baseUrl.replace(/\/+$/, '')}.json`, options);
};
// The catalog list links each toolset to its details, which carry the readme and tools
export const fetchToolsetDetails = (detailUrl, options) =>
fetchCatalogJson(detailUrl, options);
// Every catalog toolset lives in chatwoot/tools; the author is who wrote it. GitHub logins are case-insensitive
export const isChatwootAuthor = author => author?.toLowerCase() === 'chatwoot';

View File

@@ -1,34 +0,0 @@
/* global axios */
import ApiClient from '../ApiClient';
class CaptainToolsManifest extends ApiClient {
constructor() {
super('captain/tools_manifest', { accountScoped: true });
}
installed({ assistantId }, { signal } = {}) {
return axios.get(`${this.url}/installed`, {
params: { assistant_id: assistantId },
signal,
});
}
preview({ assistantId, source }, { signal } = {}) {
return axios.post(
`${this.url}/preview`,
{ assistant_id: assistantId, source },
{ signal }
);
}
install({ assistantId, source, revision, configuration }) {
return axios.post(`${this.url}/install`, {
assistant_id: assistantId,
source,
revision,
configuration,
});
}
}
export default new CaptainToolsManifest();

View File

@@ -24,25 +24,16 @@ class CompanyAPI extends ApiClient {
return axios.get(requestURL);
}
listContacts(id, page = 1, q = undefined) {
return axios.get(`${this.url}/${id}/contacts?${buildParams({ page, q })}`);
listContacts(id, page = 1) {
return axios.get(`${this.url}/${id}/contacts?${buildParams({ page })}`);
}
listNotes(id, page = 1, q = undefined) {
return axios.get(`${this.url}/${id}/notes?${buildParams({ page, q })}`);
listNotes(id) {
return axios.get(`${this.url}/${id}/notes`);
}
listConversations(id, page = 1) {
return axios.get(
`${this.url}/${id}/conversations?${buildParams({ page })}`
);
}
filterConversations(id, payload, page = 1) {
return axios.post(`${this.url}/${id}/conversations/filter`, {
payload,
page,
});
listConversations(id) {
return axios.get(`${this.url}/${id}/conversations`);
}
searchContacts(id, query = '', page = 1) {
@@ -64,10 +55,6 @@ class CompanyAPI extends ApiClient {
});
}
enrich(id) {
return axios.post(`${this.url}/${id}/enrichment`);
}
destroyAvatar(id) {
return axios.delete(`${this.url}/${id}/avatar`);
}

View File

@@ -10,12 +10,6 @@ class EnterpriseAccountAPI extends ApiClient {
return axios.post(`${this.url}checkout`);
}
reconnectShopify(pendingInstallToken) {
return axios.post(`${this.url}reconnect_shopify`, {
pending_install_token: pendingInstallToken,
});
}
subscription() {
return axios.post(`${this.url}subscription`);
}

View File

@@ -120,12 +120,6 @@ class ConversationApi extends ApiClient {
return axios.post(`${this.url}/${conversationId}/transcript`, { email });
}
getContactInfoRequestAvailability(conversationId, { signal } = {}) {
return axios.get(`${this.url}/${conversationId}/contact_info_request`, {
signal,
});
}
requestContactInfo(conversationId) {
return axios.post(`${this.url}/${conversationId}/contact_info_request`);
}

View File

@@ -13,8 +13,6 @@ export const buildCreatePayload = ({
toEmails = '',
templateParams,
isVoiceMessage = false,
emailHtmlContent,
forwardedAttachmentIds,
}) => {
let payload;
if (files && files.length !== 0) {
@@ -39,12 +37,6 @@ export const buildCreatePayload = ({
if (isVoiceMessage) {
payload.append('is_voice_message', true);
}
if (emailHtmlContent) {
payload.append('email_html_content', emailHtmlContent);
}
forwardedAttachmentIds?.forEach(id => {
payload.append('forwarded_attachment_ids[]', id);
});
} else {
payload = {
content: message,
@@ -55,8 +47,6 @@ export const buildCreatePayload = ({
bcc_emails: bccEmails,
to_emails: toEmails,
template_params: templateParams,
email_html_content: emailHtmlContent,
forwarded_attachment_ids: forwardedAttachmentIds,
};
}
return payload;
@@ -79,8 +69,6 @@ class MessageApi extends ApiClient {
toEmails = '',
templateParams,
isVoiceMessage = false,
emailHtmlContent,
forwardedAttachmentIds,
}) {
return axios({
method: 'post',
@@ -96,8 +84,6 @@ class MessageApi extends ApiClient {
toEmails,
templateParams,
isVoiceMessage,
emailHtmlContent,
forwardedAttachmentIds,
}),
});
}

View File

@@ -33,6 +33,12 @@ class IntegrationsAPI extends ApiClient {
deleteHook(hookId) {
return axios.delete(`${this.baseUrl()}/integrations/hooks/${hookId}`);
}
connectShopify({ shopDomain }) {
return axios.post(`${this.baseUrl()}/integrations/shopify/auth`, {
shop_domain: shopDomain,
});
}
}
export default new IntegrationsAPI();

View File

@@ -7,21 +7,11 @@ class ShopifyAPI extends ApiClient {
super('integrations/shopify', { accountScoped: true });
}
connect(shopDomain) {
return axios.post(`${this.url}/auth`, { shop_domain: shopDomain });
}
getOrders(contactId) {
return axios.get(`${this.url}/orders`, {
params: { contact_id: contactId },
});
}
completeInstall(pendingInstallToken) {
return axios.post(`${this.url}/complete_install`, {
pending_install_token: pendingInstallToken,
});
}
}
export default new ShopifyAPI();

View File

@@ -1,25 +0,0 @@
/* global axios */
import ApiClient from '../ApiClient';
class StripeAPI extends ApiClient {
constructor() {
super('integrations/stripe', { accountScoped: true });
}
connect() {
return axios.post(`${this.url}/auth`);
}
disconnect() {
return axios.delete(this.url);
}
customer(conversationId, customerId, signal) {
return axios.get(`${this.url}/customer`, {
params: { conversation_id: conversationId, customer_id: customerId },
signal,
});
}
}
export default new StripeAPI();

View File

@@ -107,62 +107,5 @@ describe('#ConversationAPI', () => {
expect(formPayload).toBeInstanceOf(FormData);
expect(formPayload.get('is_voice_message')).toBeNull();
});
it('appends the forwarded email fields to the form payload', () => {
const formPayload = buildCreatePayload({
message: 'Forwarding this',
echoId: 44,
isPrivate: false,
toEmails: 'vendor@example.com',
contentAttributes: { forwarded_message_id: 501 },
emailHtmlContent: '<p>Edited body</p>',
forwardedAttachmentIds: [7, 8],
files: [new Blob(['test-content'], { type: 'application/pdf' })],
});
expect(formPayload.get('to_emails')).toEqual('vendor@example.com');
expect(formPayload.get('content_attributes')).toEqual(
'{"forwarded_message_id":501}'
);
expect(formPayload.get('email_html_content')).toEqual(
'<p>Edited body</p>'
);
expect(formPayload.getAll('forwarded_attachment_ids[]')).toEqual([
'7',
'8',
]);
});
it('does not append forwarded email fields when they are absent', () => {
const formPayload = buildCreatePayload({
message: 'regular',
echoId: 45,
isPrivate: false,
files: [new Blob(['test-content'], { type: 'application/pdf' })],
});
expect(formPayload.get('email_html_content')).toBeNull();
expect(formPayload.getAll('forwarded_attachment_ids[]')).toEqual([]);
});
it('includes the forwarded email fields in the object payload', () => {
expect(
buildCreatePayload({
message: 'Forwarding this',
isPrivate: false,
echoId: 46,
toEmails: 'vendor@example.com',
contentAttributes: { forwarded_message_id: 501 },
emailHtmlContent: '<p>Edited body</p>',
forwardedAttachmentIds: [7],
})
).toMatchObject({
content: 'Forwarding this',
to_emails: 'vendor@example.com',
content_attributes: { forwarded_message_id: 501 },
email_html_content: '<p>Edited body</p>',
forwarded_attachment_ids: [7],
});
});
});
});

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