Compare commits
2 Commits
develop
...
feat/widge
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6730093a84 | ||
|
|
d63a71c59d |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
41
.github/workflows/migration_version_check.yml
vendored
41
.github/workflows/migration_version_check.yml
vendored
@@ -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
|
||||
@@ -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'
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -2,3 +2,4 @@
|
||||
//= link administrate/application.css
|
||||
//= link administrate/application.js
|
||||
//= link administrate-field-active_storage/application.css
|
||||
//= link secretField.js
|
||||
|
||||
45
app/assets/javascripts/secretField.js
Normal file
45
app/assets/javascripts/secretField.js
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
96
app/assets/stylesheets/administrate/base/_forms.scss
Normal file
96
app/assets/stylesheets/administrate/base/_forms.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
22
app/assets/stylesheets/administrate/base/_layout.scss
Normal file
22
app/assets/stylesheets/administrate/base/_layout.scss
Normal 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%;
|
||||
}
|
||||
19
app/assets/stylesheets/administrate/base/_lists.scss
Normal file
19
app/assets/stylesheets/administrate/base/_lists.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
71
app/assets/stylesheets/administrate/base/_tables.scss
Normal file
71
app/assets/stylesheets/administrate/base/_tables.scss
Normal 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;
|
||||
}
|
||||
44
app/assets/stylesheets/administrate/base/_typography.scss
Normal file
44
app/assets/stylesheets/administrate/base/_typography.scss
Normal 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;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
.app-container {
|
||||
align-items: stretch;
|
||||
display: flex;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
max-width: 100rem;
|
||||
min-height: 100vh;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
50
app/assets/stylesheets/administrate/components/_buttons.scss
Normal file
50
app/assets/stylesheets/administrate/components/_buttons.scss
Normal 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;
|
||||
}
|
||||
70
app/assets/stylesheets/administrate/components/_cells.scss
Normal file
70
app/assets/stylesheets/administrate/components/_cells.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
28
app/assets/stylesheets/administrate/components/_flashes.scss
Normal file
28
app/assets/stylesheets/administrate/components/_flashes.scss
Normal 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%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.form-actions {
|
||||
margin-left: calc(15% + 1.25rem);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
15
app/assets/stylesheets/administrate/components/_reports.scss
Normal file
15
app/assets/stylesheets/administrate/components/_reports.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
44
app/assets/stylesheets/administrate/components/_search.scss
Normal file
44
app/assets/stylesheets/administrate/components/_search.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
23
app/assets/stylesheets/administrate/custom_styles.scss
Normal file
23
app/assets/stylesheets/administrate/custom_styles.scss
Normal 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;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
@mixin administrate-clearfix {
|
||||
&::after {
|
||||
clear: both;
|
||||
content: '';
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
61
app/assets/stylesheets/administrate/library/_variables.scss
Normal file
61
app/assets/stylesheets/administrate/library/_variables.scss
Normal 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;
|
||||
448
app/assets/stylesheets/administrate/reset/_normalize.scss
Normal file
448
app/assets/stylesheets/administrate/reset/_normalize.scss
Normal 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;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.text-color-red {
|
||||
color: $alert-color;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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?
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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],
|
||||
|
||||
@@ -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!
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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'
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
@@ -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
Reference in New Issue
Block a user