Compare commits

...

11 Commits

Author SHA1 Message Date
Sivin Varghese
bc60ffc312 chore: remove old Captain label suggestions (#16056)
# Pull Request Template

## Description


This PR removes the old Captain label suggestion card above the reply
box. Label suggestions now come from the Captain suggestions in the
conversation sidebar.

This also removes the Label Suggestion toggle in Captain settings and
the "Show label suggestions" option on the OpenAI integration. A
migration clears old saved values so existing accounts keep saving.

## Type of change

- [x] Breaking change (fix or feature that would cause existing
functionality not to work as expected)



## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
2026-10-06 16:58:12 -07:00
Sivin Varghese
ab0182a9eb fix: ignore Enter while an IME composition is active in the reply box (#16102)
# Pull Request Template

## Description


Agents using an input method editor (Vietnamese Telex, Chinese,
Japanese, Korean) press Enter once to confirm the word being composed
and again to send. The reply box treated the first Enter as send, so the
full message was sent, the editor cleared, and the browser inserted the
half-finished word back into the empty box. The second Enter then sent
that fragment as a separate message.

The reply box now ignores Enter and Cmd/Ctrl+Enter while an IME
composition is in progress, so only the second Enter sends the message.


Fixes
https://linear.app/chatwoot/issue/CW-8345/reply-box-sends-the-message-when-enter-confirms-an-ime-word-then-the

## Type of change

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

## How Has This Been Tested?

### How to reproduce


1. Switch to an IME input source, for example Vietnamese Telex on macOS,
and set the send key to Enter.
2. Type a sentence and stop while the last word is still underlined.
3. Press Enter once. Before this change, the whole message is sent and
the word reappears alone in the editor. After this change, nothing is
sent and the word is confirmed.
4. Press Enter again. The complete message is sent once.
## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-10-06 16:28:42 -07:00
Vishnu Narayanan
d79c052624 perf: count assignment rate limits with a sorted set instead of scanning the keyspace (#16095)
## Description

`AutoAssignment::RateLimiter` counted an agent's recent assignments with
`Redis::Alfred.keys_count`, which runs `SCAN` over the whole keyspace
once per candidate agent per conversation on every assignment run. On a
large keyspace that is thousands of round trips per check, and the cost
scales with keyspace size rather than with assignments.

This stores assignments in one sorted set per inbox and agent (member:
conversation id, score: assignment time), trims entries older than the
window on write, expires the key with the window, and counts with
`ZCOUNT`. Limits, windows and defaults are unchanged.

Existing per-assignment keys are not migrated: windows in progress at
deploy time reset, and the old keys expire on their own.

Fixes https://linear.app/chatwoot/issue/INF-116

## Type of change

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

## How Has This Been Tested?

`bundle exec rspec spec/services/auto_assignment
spec/jobs/auto_assignment` (62 examples, 0 failures). The rate limiter
spec now asserts Redis state directly, including trimming and the
sliding window via `travel_to`.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-10-07 04:44:10 +05:30
Vishnu Narayanan
5f21903a7b chore: guard against migration version collisions (#16143)
## Description

Migrations with hand-written, rounded versions (e.g. `20260925000000`)
keep colliding across branches. Git merges two such files cleanly
because the filenames differ, and spec CI stays green because it loads
the schema instead of running migrations. The failure only shows up on a
real `db:migrate`: a `DuplicateMigrationVersionError`, or a database
that already recorded one branch's migration silently skipping the
other.

This adds:
- A `Migration Version Check` workflow on PRs that touch `db/migrate`.
It fails when a migration added or renamed in the PR has a version
ending in `0000`, and when two files in `db/migrate` share a version.
Existing migrations are not flagged.
- An `AGENTS.md` rule to create migrations with `rails g migration`
instead of writing the version by hand.

Linear: https://linear.app/chatwoot/issue/INF-118

## Type of change

CI and contributor guidelines only, no app code changes.

## How Has This Been Tested?

Ran both steps locally against simulated PR merge commits on top of
develop: a generated timestamp passes, `...000000` and `...090000`
versions fail, renaming a migration to a rounded version fails, and a
duplicate of an existing version fails. Develop as-is passes both
checks.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-10-06 14:17:48 -07:00
Shivam Mishra
f9cbacaa9e perf: skip the account lookup when reading online users (#16131)
Presence pings from the dashboard, mobile app, and widget now run one
less Postgres query each. Agents and visitors see no change in behavior.

## What changed

- `OnlineStatusTracker.get_available_user_ids` loaded the `Account` row
only to reach `account_users`. It now queries `AccountUser` by
`account_id` and plucks the user ids.
- `OnlineStatusTracker.get_availability_from_db` gets the same change.
Its account lookup was served by the query cache until now, and would
have become a real query once the first lookup was gone.

## Why

`RoomChannel#update_presence` calls `get_available_user_ids` on every
ping. On Chatwoot Cloud that was one `Account` query per ping, about
367M a week, averaging 2.9 ms each as measured from the app. The method
was written for auto-assignment, where one extra query per assignment
costs little.

Auto-assignment and inbox agent availability call the same method and
return the same results as before.

<details>
<summary>Benchmark</summary>

Based on local benchmarking of 40,000 pings over 5 threads, the change
cuts Postgres queries from 2 per ping to 1. Throughput rose from about
1,050 pings a second to between 1,460 and 1,600. Median ping time fell
from 4.1 ms to 2.8 ms.

The run used the production traffic mix of 7% agent pings and 93% widget
pings. It ran against a local database and Redis in development mode, so
absolute times in production will differ.

### Script

Save it as `presence_concurrent.rb` and run it with `bundle exec rails
runner presence_concurrent.rb`. `TOTAL` and `THREADS` set the number of
pings and threads. It reads Postgres only, and it writes presence
heartbeats to the local Redis. The `Before` module is a copy of the
method on `develop`, so both versions run in one process.

```ruby
# Runs presence pings from several threads at once, before and after the change.
# Reads Postgres only. Writes presence heartbeats to the local Redis, as a real ping does.
TOTAL = (ENV['TOTAL'] || 40_000).to_i
THREADS = (ENV['THREADS'] || 5).to_i
AGENT_SHARE = 7 # percent of pings that come from agents, as measured in production

# The method as it is on develop
module Before
  def self.get_available_users(account_id)
    user_ids = get_available_user_ids(account_id)
    return {} if user_ids.blank?

    user_availabilities = ::Redis::Alfred.hmget(OnlineStatusTracker.status_key(account_id), user_ids)
    user_ids.map.with_index { |id, index| [id, (user_availabilities[index] || OnlineStatusTracker.get_availability_from_db(account_id, id))] }.to_h
  end

  def self.get_available_user_ids(account_id)
    account = Account.find(account_id)
    range_start = (Time.zone.now - OnlineStatusTracker::PRESENCE_DURATION).to_i
    user_ids = ::Redis::Alfred.zrangebyscore(OnlineStatusTracker.presence_key(account_id, 'User'), range_start, '+inf')
    user_ids += account.account_users.where(auto_offline: false)&.map(&:user_id)&.map(&:to_s)
    user_ids.uniq
  end
end

PG_QUERIES = Concurrent::AtomicFixnum.new
ActiveSupport::Notifications.subscribe('sql.active_record') do |*, payload|
  PG_QUERIES.increment if !payload[:cached] && payload[:name] != 'SCHEMA' && payload[:name].present?
end

accounts = Account.where(id: Conversation.distinct.pluck(:account_id)).to_a
users = accounts.flat_map { |a| a.account_users.pluck(:user_id).map { |id| [a.id, 'User', id] } }
contacts = accounts.flat_map { |a| a.contacts.limit(3000).pluck(:id).map { |id| [a.id, 'Contact', id] } }
random = Random.new(42)
work = Array.new(TOTAL) { random.rand(100) < AGENT_SHARE ? users.sample(random: random) : contacts.sample(random: random) }
puts "accounts=#{accounts.size} users=#{users.size} contacts=#{contacts.size} threads=#{THREADS} db_pool=#{ActiveRecord::Base.connection_pool.size} env=#{Rails.env}"

# One ping, as RoomChannel#update_presence runs it inside the Rails executor
ping = lambda do |tracker, account_id, type, id|
  Rails.application.executor.wrap do
    OnlineStatusTracker.update_presence(account_id, type, id)
    data = { account_id: account_id, users: tracker.get_available_users(account_id) }
    data[:contacts] = OnlineStatusTracker.get_available_contacts(account_id) if type == 'User'
    data
  end
end

run = lambda do |tracker|
  queue = Queue.new
  work.each { |item| queue << item }
  latencies = Array.new(THREADS) { [] }
  PG_QUERIES.value = 0
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  THREADS.times.map do |index|
    Thread.new do
      loop do
        item = begin
          queue.pop(true)
        rescue ThreadError
          break
        end
        t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
        ping.call(tracker, *item)
        latencies[index] << (Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000
      end
    end
  end.each(&:join)
  elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
  all = latencies.flatten.sort
  { elapsed: elapsed, rate: TOTAL / elapsed, avg: all.sum / all.size, p50: all[all.size / 2], p95: all[(all.size * 0.95).to_i],
    p99: all[(all.size * 0.99).to_i], pg: PG_QUERIES.value }
end

work.first(500).each { |item| ping.call(Before, *item) && ping.call(OnlineStatusTracker, *item) }

[['before', Before], ['after', OnlineStatusTracker], ['before', Before], ['after', OnlineStatusTracker]].each do |label, tracker|
  r = run.call(tracker)
  puts format('%-7s %6.1f s  %6.0f pings/s  avg %.2f ms  p50 %.2f  p95 %.2f  p99 %.2f  postgres queries %d (%.2f per ping)',
              label, r[:elapsed], r[:rate], r[:avg], r[:p50], r[:p95], r[:p99], r[:pg], r[:pg] / TOTAL.to_f)
end
```

</details>
2026-10-06 14:11:11 -07:00
Shivam Mishra
225b2af051 feat: make the refreshed Captain Overview the default (#15980)
Captain Overview now opens the refreshed dashboard for everyone. This
also upgrades its chart library to @chatwoot/viz 0.1.6.
2026-10-06 14:00:26 -07:00
Sivin Varghese
ccbda8b37f feat: redesign Super Admin panel (#16137)
# Pull Request Template

## Description



Fixes https://linear.app/chatwoot/issue/CW-8369/super-admin-redesign

## Type of change

- [x] New feature (non-breaking change which adds functionality)



## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-10-06 13:54:08 -07:00
Pranav
a296aa9191 fix(companies): attach logo on enrichment and label authorless notes as System (#16144)
Enriching a company now also sets its logo. Previously enrichment filled
in the profile (industry, size, location, socials) but left the avatar
untouched, so companies kept a low-resolution favicon or no logo at all,
even after "Company profile refreshed." In the company notes feed, notes
without an author now show as **System** with a gear icon instead of
"Someone".

## How to reproduce

1. Open a company that has a domain but no logo (or only a blurry
favicon), on an account with company enrichment enabled.
2. Click **Enrich**. The profile fields refresh, but the logo does not
change.
3. Open the **Notes** tab for a company that has a note created without
an agent (for example via automation or the API). The author shows as
"Someone" with initials.

## What changed

- `Companies::EnrichmentService` attaches the Context.dev logo as the
company avatar, preferring the square `icon` variant and falling back to
the first logo.
- Automatic enrichment on company creation only fills a missing avatar.
The manual **Enrich** action replaces the existing avatar, matching how
it already overwrites other fields.
- Logos in formats avatars do not accept (such as SVG) are skipped and
the existing avatar is kept.
- Authorless notes in the company notes feed render with the name
"System" and a gear icon.

## Notes

- Companies created before favicon fetching existed still have no avatar
until they are enriched or `rake companies:fetch_missing_avatars` is
run.
- For newly created companies, the favicon job can attach before
automatic enrichment finishes, in which case the favicon stays until
someone clicks **Enrich**.
2026-10-06 09:18:22 -07:00
Muhsin Keloth
52a6adedf8 fix(stripe): log safe details for customer fetch failures (#16153)
Stripe customer lookups currently return `stripe_unavailable` without
enough information to identify the underlying provider failure. This
adds minimal server-side diagnostics for Stripe API and network errors
while keeping the customer-facing response unchanged.

### What changed

Log the error class and, for Stripe API errors, the error code, HTTP
status, and Stripe request ID. Raw response bodies, exception messages,
credentials, and customer data are excluded.

### Things to know

Authentication and OAuth error handling remain unchanged. No migrations,
configuration changes, or feature-flag changes are required.

### How to reproduce

With Stripe connected, a customer lookup that encounters a Stripe API or
network error returns `stripe_unavailable`, but previously did not log
the provider details.

### How to test

In a test environment, trigger a customer lookup with a simulated Stripe
API failure. Confirm the response remains `422` with
`stripe_unavailable` and the server log contains only the safe error
details. Confirm successful customer lookups still display billing
information normally.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-10-06 20:01:48 +04:00
Muhsin Keloth
f1c9e4831b feat(stripe): view customer billing details in conversations (#15765)
Adds a read-only Stripe integration so support agents can view customer
details, subscriptions, and recent invoices directly in the conversation
sidebar without asking a teammate to look them up in Stripe.

Account administrators connect through OAuth and manage the connection
from Settings → Integrations. Super Admin controls the Stripe app
configuration. Sandbox and live connections are supported; Chatwoot's
own subscription billing is unchanged.

### Things to know

- This is an initial read-only integration. Refunds, payment actions,
and webhook-driven updates are not included.
- Customer lookup uses the conversation contact's email. When multiple
Stripe customers match, the agent selects one. Billing data is scoped to
conversations the agent can access.
- Customer matching uses Stripe Search with an exact case-insensitive
email check. Newly created or edited customers may not appear
immediately because of indexing delays. Stripe Search is unavailable to
businesses in India; this integration currently shares that limitation.
See [Stripe Search
limitations](https://docs.stripe.com/search#limitations).
- Connections reuse integration hooks, with encrypted OAuth credentials
and token refresh. No new database table is required. Active Record
encryption must be configured.
- Configure the matching OAuth installation link and developer secret in
Super Admin → Settings → Stripe. The Stripe app must allow the
installation's `/stripe/callback` URL. Each installation uses one
credential pair; changing environments requires reconnecting accounts.
- Disconnecting removes the local credentials. Uninstall the app in
Stripe to revoke provider-side access.
- The sandbox flow has been exercised locally. Live-mode behavior has
automated coverage, but live credentials, an internal live pilot,
deployment, and Marketplace publishing are deferred.

### How to test

1. Configure a sandbox OAuth link and matching developer key in Super
Admin → Settings → Stripe, with Active Record encryption enabled.
2. As an account administrator, open Settings → Integrations → Stripe
and connect a sandbox. Confirm the connected account, environment, date,
and dashboard link.
3. Create a Stripe customer whose email matches a Chatwoot contact, with
a subscription and invoices. Open that contact's conversation and expand
Stripe to see the customer, subscription status, and recent invoice
amounts, dates, and statuses.
4. Check a contact with no email, no matching customer, and multiple
matching customers. The sidebar should show the appropriate empty state
or customer selector.
Use a mixed-case email in Stripe to verify case-insensitive matching,
and edit the contact email with the sidebar open to verify it reloads
and clears the old customer selection. Allow time for Stripe's search
index to update.
5. Confirm agents cannot manage the connection or retrieve Stripe data
through an inaccessible conversation.
6. Disconnect and confirm the sidebar is no longer available.
Live-account verification is a separate rollout step and has not been
performed.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
2026-10-06 18:10:20 +04:00
Shivam Mishra
af1e939467 feat(tools-manifest): add a Tools Catalog to browse and install toolsets [PR-4] (#16034)
Admins can now browse the Captain tools catalog and install toolsets
from it. A new "Tools Catalog" button on the tools page opens a page
listing the toolsets published in
[chatwoot/tools](https://github.com/chatwoot/tools), filterable by
category. Clicking a toolset opens a side panel with its tools, the
configuration it needs, and its README, with an Install button that
opens the install preview from #15997. Toolsets the assistant already
has are marked "Installed", or "Update available" when the catalog has a
newer version. Each toolset shows its author, and toolsets authored by
Chatwoot are listed first.

Toolsets can also be installed from a shareable link,
`/captain/toolsets/install?source=chatwoot/tools/<folder>`. It opens the
dashboard in the user's current account, asks which assistant to install
to when there is more than one, and opens the install preview over that
assistant's tools page.

This is part 4 of a series of PRs that bring toolset manifest import to
Captain, stacked on #15997.

## How to test

1. In Super Admin, enable "Captain Tools Manifest". The Captain Tools
Catalog URL defaults to `https://www.chatwoot.com/captain/tools`;
locally, point it at a deployment that serves the catalog, for example
`https://deploy-preview-322--chatwoot.netlify.app/captain/tools`.
2. On an account with the custom tools plan, open Captain > Tools and
click "Tools Catalog".
3. Switch between category pills and check the cards filter.
4. Click a card, review the tools, configuration and README in the side
panel, then click Install and complete the install.
5. Check you land back on the tools page with the new tools listed, and
that the catalog now marks that toolset as installed.
6. Open `/captain/toolsets/install?source=chatwoot/tools/better-stack`
as an admin. With more than one assistant, pick one, then complete or
cancel the install and check you end up on that assistant's tools page.
7. Turn "Captain Tools Manifest" off and check the Tools Catalog button
is hidden on the tools page.

## Changes

- `CAPTAIN_TOOLS_CATALOG_URL` installation config, exposed to the
dashboard through the global config.
- Tools Catalog page at `/captain/:assistantId/tools/explore` with
category pills and a three column card grid.
- Toolset details side panel with tools, configuration fields, README,
and an Install button for admins.
- Installed and update available badges, backed by a new `GET
captain/tools_manifest/installed` endpoint.
- Shareable install link at `/captain/toolsets/install?source=...`, with
an assistant picker when the account has more than one.
- The "Captain Tools Manifest" setting only hides the Tools Catalog
button; the preview and install APIs still enforce it. The catalog, its
install actions and the install link stay behind the custom tools
paywall.
- Custom tool templates no longer render with `strict_variables`, so `{%
if response.error %}` and `| default:` work on optional fields. Liquid
syntax errors still fail the call.
- Custom tool endpoint URLs may contain Liquid tags (`{% %}`); the
save-time URL check ignores them, and SafeFetch still checks the real
host on every call.
- The catalog is fetched from the browser without credentials, so the
catalog host must allow cross-origin requests.

## Known limitations

- An install link opened while logged out, or in an account with no
assistants yet, does not resume after login or assistant creation.
Opening the link again works.
2026-10-06 19:04:18 +05:30
287 changed files with 7378 additions and 5159 deletions

View File

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

View File

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

View File

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

View File

@@ -1,45 +0,0 @@
// eslint-disable-next-line
function toggleSecretField(e) {
e.preventDefault();
e.stopPropagation();
const toggler = e.currentTarget;
const secretField = toggler.parentElement;
const textElement = secretField.querySelector('[data-secret-masked]');
if (!textElement) return;
if (textElement.dataset.secretMasked === 'false') {
const maskedLength = secretField.dataset.secretText?.length || 10;
textElement.textContent = '•'.repeat(maskedLength);
textElement.dataset.secretMasked = 'true';
toggler.querySelector('svg use').setAttribute('xlink:href', '#eye-show');
return;
}
textElement.textContent = secretField.dataset.secretText;
textElement.dataset.secretMasked = 'false';
toggler.querySelector('svg use').setAttribute('xlink:href', '#eye-hide');
}
// eslint-disable-next-line
function copySecretField(e) {
e.preventDefault();
e.stopPropagation();
const toggler = e.currentTarget;
const secretField = toggler.parentElement;
navigator.clipboard.writeText(secretField.dataset.secretText);
}
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.cell-data__secret-field').forEach(field => {
const span = field.querySelector('[data-secret-masked]');
if (span && span.dataset.secretMasked === 'true') {
const len = field.dataset.secretText?.length || 10;
span.textContent = '•'.repeat(len);
}
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -28,6 +28,7 @@ class DashboardController < ActionController::Base
DEPLOYMENT_ENV
INSTALLATION_PRICING_PLAN
CAPTAIN_TOOLS_MANIFEST_ENABLED
CAPTAIN_TOOLS_CATALOG_URL
].freeze
before_action :set_application_pack

View File

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

View File

@@ -29,7 +29,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
end
def create
errors = shopify_partner_config_errors
errors = shopify_partner_config_errors + stripe_config_errors
params['app_config'].each do |key, value|
break if errors.any?
next unless @allowed_configs.include?(key)
@@ -39,15 +39,19 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
errors.concat(i.errors.full_messages) unless i.save
end
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
redirect_to super_admin_app_config_path(config: @config), flash: errors.any? ? { alert: errors.join(', ') } : success_flash
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
@@ -61,6 +65,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
'microsoft' => %w[AZURE_APP_ID AZURE_APP_SECRET],
'email' => %w[MAILER_INBOUND_EMAIL_DOMAIN ACCOUNT_EMAILS_LIMIT ACCOUNT_EMAILS_PLAN_LIMITS],
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
'stripe' => %w[STRIPE_APP_AUTHORIZE_URL STRIPE_APP_SECRET_KEY],
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET SLACK_SIGNING_SECRET],
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
'tiktok' => %w[TIKTOK_APP_ID TIKTOK_APP_SECRET TIKTOK_API_VERSION],

View File

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

View File

@@ -16,6 +16,13 @@ class PlatformBannerDashboard < Administrate::BaseDashboard
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
def display_resource(platform_banner)
"Banner ##{platform_banner.id} (#{platform_banner.banner_type})"
end

View File

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

View File

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

View File

@@ -7,6 +7,17 @@ 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|
@@ -37,16 +48,12 @@ module SuperAdmin::AccountFeaturesHelper
filtered = filter_deprecated_features(filtered)
display_names = feature_display_names
regular, premium = filtered.partition { |key, _value| account_premium_features.exclude?(key) }
premium_features = account_premium_features
regular, premium = filtered.partition { |key, _value| premium_features.exclude?(key) }
[
sort_and_transform_features(regular, display_names),
sort_and_transform_features(premium, display_names)
]
end
def self.filtered_features(features)
regular, premium = partition_features(features)
regular.merge(premium)
end
end

View File

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

View File

@@ -6,39 +6,39 @@ saml:
name: 'SAML SSO'
description: 'Configuration for controlling SAML Single Sign-On availability'
enabled: <%= ChatwootApp.enterprise? %>
icon: 'icon-lock-line'
icon: 'i-lucide-key-round'
config_key: 'saml'
enterprise: true
custom_branding:
name: 'Custom Branding'
description: 'Apply your own branding to this installation.'
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
icon: 'icon-paint-brush-line'
icon: 'i-lucide-palette'
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: 'icon-hourglass-line'
icon: 'i-lucide-gauge'
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: 'icon-menu-search-line'
icon: 'i-lucide-scroll-text'
enterprise: true
disable_branding:
name: 'Disable Branding'
description: 'Disable branding on live-chat widget and external emails.'
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
icon: 'icon-sailbot-fill'
icon: 'i-lucide-eye-off'
enterprise: true
voice_calls:
name: 'Voice Calls'
description: 'Enable voice calling capabilities for your agents and customers.'
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
icon: 'icon-voice-line'
icon: 'i-lucide-phone-call'
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: 'icon-book-2-line'
icon: 'i-lucide-book-open'
captain:
name: 'Captain'
description: 'Enable AI-powered conversations with your customers.'
enabled: true
icon: 'icon-captain'
icon: 'i-woot-captain text-n-violet-9'
config_key: 'captain'
# ------- Communication Channels ------- #
@@ -59,94 +59,100 @@ live_chat:
name: 'Live Chat'
description: 'Improve your customer experience using a live chat on your website.'
enabled: true
icon: 'icon-chat-smile-3-line'
icon: 'i-lucide-message-circle'
email:
name: 'Email'
description: 'Manage your email customer interactions from Chatwoot.'
enabled: true
icon: 'icon-mail-send-fill'
icon: 'i-lucide-mail'
config_key: 'email'
sms:
name: 'SMS'
description: 'Manage your SMS customer interactions from Chatwoot.'
enabled: true
icon: 'icon-message-line'
icon: 'i-lucide-message-square-text'
messenger:
name: 'Messenger'
description: 'Stay connected with your customers on Facebook & Instagram.'
enabled: true
icon: 'icon-messenger-line'
icon: 'i-logos-messenger'
config_key: 'facebook'
instagram:
name: 'Instagram'
description: 'Stay connected with your customers on Instagram'
enabled: true
icon: 'icon-instagram'
icon: 'i-logos-instagram-icon dark:invert'
config_key: 'instagram'
tiktok:
name: 'TikTok'
description: 'Stay connected with your customers on TikTok'
enabled: true
icon: 'icon-tiktok'
icon: 'i-logos-tiktok-icon dark:invert'
config_key: 'tiktok'
whatsapp:
name: 'WhatsApp'
description: 'Manage your WhatsApp business interactions from Chatwoot.'
enabled: true
icon: 'icon-whatsapp-line'
icon: 'i-logos-whatsapp-icon'
telegram:
name: 'Telegram'
description: 'Manage your Telegram customer interactions from Chatwoot.'
enabled: true
icon: 'icon-telegram-line'
icon: 'i-logos-telegram'
line:
name: 'Line'
description: 'Manage your Line customer interactions from Chatwoot.'
enabled: true
icon: 'icon-line-line'
icon: 'i-ri-line-fill text-[#06C755]'
# ------- OAuth & Authentication ------- #
google:
name: 'Google'
description: 'Configuration for setting up Google OAuth Integration'
enabled: true
icon: 'icon-google'
icon: 'i-logos-google-icon'
config_key: 'google'
microsoft:
name: 'Microsoft'
description: 'Configuration for setting up Microsoft Email'
enabled: true
icon: 'icon-microsoft'
icon: 'i-logos-microsoft-icon'
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: 'icon-linear'
icon: 'i-logos-linear-icon dark:invert'
config_key: 'linear'
notion:
name: 'Notion'
description: 'Configuration for setting up Notion Integration'
enabled: true
icon: 'icon-notion'
icon: 'i-logos-notion-icon dark:invert'
config_key: 'notion'
slack:
name: 'Slack'
description: 'Configuration for setting up Slack Integration'
enabled: true
icon: 'icon-slack'
icon: 'i-logos-slack-icon'
config_key: 'slack'
whatsapp_embedded:
name: 'WhatsApp Embedded'
description: 'Configuration for setting up WhatsApp Embedded Integration'
enabled: true
icon: 'icon-whatsapp-line'
icon: 'i-logos-whatsapp-icon'
config_key: 'whatsapp_embedded'
shopify:
name: 'Shopify'
description: 'Configuration for setting up Shopify Integration'
enabled: true
icon: 'icon-shopify'
icon: 'i-logos-shopify'
config_key: 'shopify'

View File

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

View File

@@ -1,16 +1,121 @@
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
features = SuperAdmin::FeaturesHelper.available_features.select do |_feature, attrs|
attrs['config_key'].present? && attrs['enabled']
@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
end
end
# Add general at the beginning
general_feature = [['general', { 'config_key' => 'general', 'name' => 'General' }]]
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
general_feature + features.to_a
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
end
end

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,13 @@ class CaptainToolsManifest extends ApiClient {
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`,

View File

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

View File

@@ -0,0 +1,79 @@
@layer utilities {
// ============================================================================
// TYPOGRAPHY UTILITIES
// ============================================================================
//
// | Class | Use Case |
// |--------------------|----------------------------------------------------|
// | .text-body-main | <p>, <span>, general body text |
// | .text-body-para | <p> for paragraphs, larger text blocks |
// | .text-heading-1 | <h1>, page titles, panel headers |
// | .text-heading-2 | <h2>, section headings, card titles |
// | .text-heading-3 | <h3>, card headings, breadcrumbs, subsections |
// | .text-label | <label>, form labels, field names |
// | .text-label-small | <small>, footnotes, tags, badges, captions |
// | .text-button | <button>, standard button text |
// | .text-button-small | <button>, small/compact button text |
// body-text-main: Main text style for general body text
.text-body-main {
@apply font-inter text-sm font-420;
letter-spacing: -0.28px;
line-height: 21px; // 150%
}
// body-text-paragraph: For paragraphs or larger blocks of text
.text-body-para {
@apply font-inter text-sm font-420;
letter-spacing: -0.21px;
line-height: 21px; // 150%
}
// heading-1: Large heading for pages and panels
.text-heading-1 {
@apply font-inter text-lg font-520;
letter-spacing: -0.27px;
line-height: 24px; // 133.333%
}
// heading-2: Secondary heading for sections
.text-heading-2 {
@apply font-inter text-base font-medium;
letter-spacing: -0.27px;
line-height: 24px; // 133.333%
}
// heading-3: For card headings, breadcrumbs, subsections
.text-heading-3 {
@apply font-inter text-sm font-medium;
letter-spacing: -0.27px;
line-height: 21px; // 150%
}
// label: Standard label text for form fields
.text-label {
@apply font-inter text-sm font-medium;
line-height: 21px; // 150%
}
// label-small: Smallest font for labels, footnotes, tags
.text-label-small {
@apply font-inter text-xs font-440;
letter-spacing: -0.24px;
line-height: 16px; // 133.333%
}
// button-text: Text for standard size buttons
.text-button {
@apply font-inter text-sm font-460;
letter-spacing: -0.28px;
line-height: 21px; // 150%
}
// button-text-small: Text for smaller buttons
.text-button-small {
@apply font-inter text-xs font-440;
letter-spacing: -0.24px;
line-height: 18px; // 150%
}
}

View File

@@ -6,6 +6,9 @@
@import 'shared/assets/fonts/InterDisplay/inter-display';
@import 'shared/assets/fonts/inter';
// Typography Utilities
@import 'typography';
// Next Colors
@import 'next-colors';
@@ -65,84 +68,4 @@ body {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
/**
* ============================================================================
* TYPOGRAPHY UTILITIES
* ============================================================================
*
* | Class | Use Case |
* |--------------------|----------------------------------------------------|
* | .text-body-main | <p>, <span>, general body text |
* | .text-body-para | <p> for paragraphs, larger text blocks |
* | .text-heading-1 | <h1>, page titles, panel headers |
* | .text-heading-2 | <h2>, section headings, card titles |
* | .text-heading-3 | <h3>, card headings, breadcrumbs, subsections |
* | .text-label | <label>, form labels, field names |
* | .text-label-small | <small>, footnotes, tags, badges, captions |
* | .text-button | <button>, standard button text |
* | .text-button-small | <button>, small/compact button text |
*/
/* body-text-main: Main text style for general body text */
.text-body-main {
@apply font-inter text-sm font-420;
line-height: 21px; /* 150% */
letter-spacing: -0.28px;
}
/* body-text-paragraph: For paragraphs or larger blocks of text */
.text-body-para {
@apply font-inter text-sm font-420;
line-height: 21px; /* 150% */
letter-spacing: -0.21px;
}
/* heading-1: Large heading for pages and panels */
.text-heading-1 {
@apply font-inter text-lg font-520;
line-height: 24px; /* 133.333% */
letter-spacing: -0.27px;
}
/* heading-2: Secondary heading for sections */
.text-heading-2 {
@apply font-inter text-base font-medium;
line-height: 24px; /* 133.333% */
letter-spacing: -0.27px;
}
/* heading-3: For card headings, breadcrumbs, subsections */
.text-heading-3 {
@apply font-inter text-sm font-medium;
line-height: 21px; /* 150% */
letter-spacing: -0.27px;
}
/* label: Standard label text for form fields */
.text-label {
@apply font-inter text-sm font-medium;
line-height: 21px; /* 150% */
}
/* label-small: Smallest font for labels, footnotes, tags */
.text-label-small {
@apply font-inter text-xs font-440;
line-height: 16px; /* 133.333% */
letter-spacing: -0.24px;
}
/* button-text: Text for standard size buttons */
.text-button {
@apply font-inter text-sm font-460;
line-height: 21px; /* 150% */
letter-spacing: -0.28px;
}
/* button-text-small: Text for smaller buttons */
.text-button-small {
@apply font-inter text-xs font-440;
line-height: 18px; /* 150% */
letter-spacing: -0.24px;
}
}

View File

@@ -0,0 +1,36 @@
// Selectize is the one widget whose markup we do not render, so it cannot take
// utilities in the templates. These selectors mirror its own specificity.
.selectize-control .selectize-input,
.selectize-control.single .selectize-input.input-active {
@apply flex min-h-9 items-center rounded-lg border border-n-slate-6 bg-n-solid-1 px-3 py-1.5 text-body-main text-n-slate-12 shadow-none hover:border-n-slate-8 touch:min-h-11 touch:text-base;
background-image: none;
&.focus {
@apply border-n-brand ring-2 ring-n-brand/25;
}
&.dropdown-active::before {
@apply hidden;
}
> input {
@apply text-body-main text-n-slate-12 touch:text-base;
}
}
.selectize-control.single .selectize-input::after {
@apply end-3 border-x-4 border-b-0 border-t-4 border-x-transparent border-t-n-slate-10;
}
.selectize-dropdown {
@apply mt-1 overflow-hidden rounded-lg border border-n-weak bg-n-solid-2 text-body-main text-n-slate-12 shadow-lg;
.option {
@apply px-3 py-2;
}
.active {
@apply bg-n-alpha-2 text-n-slate-12;
}
}

View File

@@ -0,0 +1,27 @@
// Utilities only the super admin uses, next to Tailwind's own and the shared typography ones.
@layer utilities {
// The square mosaic from chatwoot.com, drawn with gradients so it needs no image
// and follows the theme tokens.
.bg-pixel-band {
background-image: linear-gradient(to right, rgb(var(--solid-1)) 0.0625rem, transparent 0.0625rem),
linear-gradient(to bottom, rgb(var(--solid-1)) 0.0625rem, transparent 0.0625rem),
radial-gradient(circle at 88% 10%, rgb(var(--blue-9) / 0.6), transparent 38%),
radial-gradient(circle at 62% 110%, rgb(var(--blue-8) / 0.55), transparent 42%),
radial-gradient(circle at 30% -20%, rgb(var(--blue-7) / 0.4), transparent 45%),
linear-gradient(to right, rgb(var(--blue-2)), rgb(var(--blue-4)));
background-size: 0.5rem 0.5rem,
0.5rem 0.5rem,
100% 100%,
100% 100%,
100% 100%,
100% 100%;
}
// Arrow for native selects, which lose theirs with `appearance-none`.
.bg-chevron {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2380838d' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
background-position: right 0.625rem center;
background-repeat: no-repeat;
background-size: 1rem;
}
}

View File

@@ -1,12 +1,12 @@
@import 'shared/assets/fonts/inter';
@import 'shared/assets/fonts/InterDisplay/inter-display';
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import '../typography';
@import 'utilities';
@import 'selectize';
@import 'tailwindcss/utilities';
body {
font-family: Inter, -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif !important;
}
@layer base {
// FIXME: Use a common color file for all packs
// scss-lint:disable PropertySortOrder
@@ -24,6 +24,19 @@ body {
--slate-11: 96 100 108;
--slate-12: 28 32 36;
--blue-1: 251 253 255;
--blue-2: 245 249 255;
--blue-3: 233 243 255;
--blue-4: 218 236 255;
--blue-5: 201 226 255;
--blue-6: 181 213 255;
--blue-7: 155 195 252;
--blue-8: 117 171 247;
--blue-9: 39 129 246;
--blue-10: 16 115 233;
--blue-11: 8 109 224;
--blue-12: 11 50 101;
--iris-1: 253 253 255;
--iris-2: 248 248 255;
--iris-3: 240 241 254;
@@ -63,6 +76,19 @@ body {
--amber-11: 171 100 0;
--amber-12: 79 52 34;
--violet-1: 253 252 254;
--violet-2: 250 248 255;
--violet-3: 244 240 254;
--violet-4: 235 228 255;
--violet-5: 225 217 255;
--violet-6: 212 202 254;
--violet-7: 194 178 248;
--violet-8: 169 153 236;
--violet-9: 110 86 207;
--violet-10: 100 84 196;
--violet-11: 101 85 183;
--violet-12: 47 38 95;
--teal-1: 250 254 253;
--teal-2: 243 251 249;
--teal-3: 224 248 243;
@@ -112,6 +138,9 @@ body {
}
.dark {
// scss-lint:disable PropertySpelling
color-scheme: dark;
--slate-1: 17 17 19;
--slate-2: 24 25 27;
--slate-3: 33 34 37;
@@ -125,6 +154,19 @@ body {
--slate-11: 176 180 186;
--slate-12: 237 238 240;
--blue-1: 10 17 28;
--blue-2: 15 24 38;
--blue-3: 15 39 72;
--blue-4: 10 49 99;
--blue-5: 18 61 117;
--blue-6: 29 84 134;
--blue-7: 40 89 156;
--blue-8: 48 106 186;
--blue-9: 39 129 246;
--blue-10: 21 116 231;
--blue-11: 126 182 255;
--blue-12: 205 227 255;
--iris-1: 19 19 30;
--iris-2: 23 22 37;
--iris-3: 32 34 72;
@@ -164,6 +206,19 @@ body {
--amber-11: 255 202 22;
--amber-12: 255 231 179;
--violet-1: 20 17 31;
--violet-2: 27 21 37;
--violet-3: 41 31 67;
--violet-4: 50 37 85;
--violet-5: 60 46 105;
--violet-6: 71 56 135;
--violet-7: 86 70 151;
--violet-8: 110 86 171;
--violet-9: 110 86 207;
--violet-10: 125 109 217;
--violet-11: 169 153 236;
--violet-12: 226 221 254;
--teal-1: 13 21 20;
--teal-2: 17 28 27;
--teal-3: 13 45 42;

View File

@@ -23,6 +23,7 @@ const props = defineProps({
const emit = defineEmits(['loadMore']);
const NOTE_CLAMP_LENGTH = 240;
const SYSTEM_AUTHOR_ICON = 'i-lucide-settings';
const { t } = useI18n();
const router = useRouter();
@@ -59,7 +60,7 @@ const noteHtml = note => {
const noteAuthor = note =>
note.user?.id === currentUser.value.id
? t('CONTACTS_LAYOUT.SIDEBAR.NOTES.YOU')
: note.user?.name || t('COMPANIES.DETAIL.ACTIVITY.UNKNOWN_AUTHOR');
: note.user?.name || t('COMPANIES.DETAIL.ACTIVITY.SYSTEM_AUTHOR');
const noteContactName = note =>
note.contact?.name || t('COMPANIES.DETAIL.CONTACTS.UNNAMED_CONTACT');
@@ -96,6 +97,7 @@ const groups = computed(() => groupByDay(props.notes, 'createdAt', t));
<Avatar
:name="noteAuthor(note)"
:src="note.user?.thumbnail"
:icon-name="note.user ? null : SYSTEM_AUTHOR_ICON"
:size="40"
hide-offline-status
class="shrink-0"

View File

@@ -15,7 +15,7 @@ vi.mock('dashboard/composables', () => ({ useAlert: mocks.alert }));
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: key => key }) }));
const DialogStub = {
emits: ['confirm'],
emits: ['confirm', 'close'],
methods: {
open() {},
close() {
@@ -63,8 +63,7 @@ const mountDialog = () =>
const loadPreview = async (wrapper, data) => {
mocks.preview.mockResolvedValue({ data });
await wrapper.find('input').setValue(' Chatwoot/Support-Tools/Shopify ');
await wrapper.findAll('button').at(-1).trigger('click');
wrapper.vm.open('Chatwoot/Tools/Shopify');
await flushPromises();
};
@@ -85,8 +84,8 @@ describe('InstallManifestDialog', () => {
expect(mocks.install).toHaveBeenCalledWith({
assistantId: 7,
// Sent as entered: the preview's identity is lowercase, but GitHub folder names are case-sensitive
source: 'Chatwoot/Support-Tools/Shopify',
// Sent as opened: the preview's identity is lowercase, but GitHub folder names are case-sensitive
source: 'Chatwoot/Tools/Shopify',
revision: previewData.revision,
configuration: { inputs: {}, secrets: { access_token: 'shpat_secret' } },
});
@@ -100,8 +99,7 @@ describe('InstallManifestDialog', () => {
return new Promise(() => {});
});
const wrapper = mountDialog();
await wrapper.find('input').setValue('chatwoot/tools/shopify');
await wrapper.findAll('button').at(-1).trigger('click');
wrapper.vm.open('chatwoot/tools/shopify');
wrapper.findComponent(DialogStub).vm.$emit('close');
@@ -124,15 +122,35 @@ describe('InstallManifestDialog', () => {
wrapper.findAll('button').at(0).attributes('disabled')
).toBeDefined();
wrapper.vm.open();
wrapper.vm.open('chatwoot/tools/shopify');
finishInstall({ data: { payload: [] } });
await flushPromises();
expect(wrapper.emitted('installed')).toHaveLength(1);
expect(wrapper.emitted('installed')).toBeUndefined();
expect(mocks.dialogClose).not.toHaveBeenCalled();
expect(mocks.alert).not.toHaveBeenCalled();
});
it('ignores an install that finishes after the dialog was dismissed', async () => {
let finishInstall;
mocks.install.mockReturnValue(
new Promise(resolve => {
finishInstall = resolve;
})
);
const wrapper = mountDialog();
await loadPreview(wrapper, previewData);
await wrapper.find('input[type="password"]').setValue('shpat_secret');
await wrapper.findAll('button').at(-1).trigger('click');
wrapper.findComponent(DialogStub).vm.$emit('close');
finishInstall({ data: { payload: [] } });
await flushPromises();
expect(wrapper.emitted('installed')).toBeUndefined();
expect(mocks.alert).not.toHaveBeenCalled();
});
it('does not treat built-in object property names as filled', async () => {
const wrapper = mountDialog();
await loadPreview(wrapper, {

View File

@@ -11,6 +11,7 @@ import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import Switch from 'dashboard/components-next/switch/Switch.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const props = defineProps({
assistantId: {
@@ -19,7 +20,7 @@ const props = defineProps({
},
});
const emit = defineEmits(['installed']);
const emit = defineEmits(['installed', 'close']);
const { t } = useI18n();
@@ -27,20 +28,15 @@ const SHORT_REVISION_LENGTH = 7;
const INPUT_TYPES = { password: 'password', number: 'number' };
const dialogRef = ref(null);
// Sent to install exactly as opened; preview returns a lowercase identity, but GitHub folder names are case-sensitive
const source = ref('');
const preview = ref(null);
// The source exactly as previewed; preview returns a lowercase identity, but GitHub folder names are case-sensitive
const previewedSource = ref('');
// Prototype-free, so field names like "constructor" don't read inherited values and look already filled
const emptyValues = () => Object.create(null);
const values = reactive({ inputs: emptyValues(), secrets: emptyValues() });
const isInstalling = ref(false);
// A slow preview must not land in a dialog that was closed or reopened for another source
const {
run: runPreview,
abort: abortPreview,
isPending: isLoadingPreview,
} = useAbortableRequest();
const { run: runPreview, abort: abortPreview } = useAbortableRequest();
const isInstalled = computed(() => !!preview.value?.up_to_date);
// Installed at this commit but some tools were deleted; reinstalling adds them back
@@ -93,23 +89,14 @@ const selectOptions = field =>
(field.options || []).map(option => ({ value: option, label: option }));
const reset = () => {
source.value = '';
preview.value = null;
previewedSource.value = '';
values.inputs = emptyValues();
values.secrets = emptyValues();
};
// Bumped on every open, so an install finishing after the dialog was reopened doesn't act on the new session
// Bumped on every open and close, so an install finishing after the dialog was dismissed or reopened is ignored
let session = 0;
const open = () => {
session += 1;
abortPreview();
reset();
dialogRef.value.open();
};
const close = () => dialogRef.value.close();
// The tools page is reused across assistants, so a preview must never be installed into the next one
@@ -123,20 +110,15 @@ watch(
);
const loadPreview = async () => {
const requestedSource = source.value.trim();
if (!requestedSource) return;
try {
const response = await runPreview(signal =>
ToolsManifestAPI.preview(
{ assistantId: props.assistantId, source: requestedSource },
{ assistantId: props.assistantId, source: source.value },
{ signal }
)
);
if (!response) return;
previewedSource.value = requestedSource;
const { data } = response;
values.inputs = emptyValues();
values.secrets = emptyValues();
@@ -151,23 +133,37 @@ const loadPreview = async () => {
parseAPIErrorResponse(error) ||
t('CAPTAIN.CUSTOM_TOOLS.INSTALL_MANIFEST.PREVIEW_ERROR')
);
// There is nothing to show without a preview
close();
}
};
// The catalog and install links open the dialog with a source, which goes straight to its preview
const open = toolsetSource => {
session += 1;
abortPreview();
reset();
// A request from before a reopen must not keep the new dialog locked
isInstalling.value = false;
source.value = toolsetSource;
dialogRef.value.open();
loadPreview();
};
const install = async () => {
const installSession = session;
isInstalling.value = true;
try {
await ToolsManifestAPI.install({
assistantId: props.assistantId,
source: previewedSource.value,
source: source.value,
// Pin to the previewed commit so a newer push can't install tools that were never reviewed
revision: preview.value.revision,
configuration: values,
});
emit('installed');
if (installSession !== session) return;
emit('installed');
useAlert(t('CAPTAIN.CUSTOM_TOOLS.INSTALL_MANIFEST.SUCCESS_MESSAGE'));
close();
} catch (error) {
@@ -178,21 +174,14 @@ const install = async () => {
t('CAPTAIN.CUSTOM_TOOLS.INSTALL_MANIFEST.ERROR_MESSAGE')
);
} finally {
isInstalling.value = false;
if (installSession === session) isInstalling.value = false;
}
};
const goBack = () => {
if (preview.value) {
preview.value = null;
} else {
close();
}
};
// The dialog is a form, so Enter in the source field submits it
const onEnter = () => {
if (!preview.value) loadPreview();
const onClose = () => {
session += 1;
abortPreview();
emit('close');
};
defineExpose({ open });
@@ -206,20 +195,10 @@ defineExpose({ open });
:description="t('CAPTAIN.CUSTOM_TOOLS.INSTALL_MANIFEST.DESCRIPTION')"
:show-cancel-button="false"
:show-confirm-button="false"
@confirm="onEnter"
@close="abortPreview"
@close="onClose"
>
<div v-if="!preview" class="flex flex-col gap-2">
<Input
v-model="source"
:label="t('CAPTAIN.CUSTOM_TOOLS.INSTALL_MANIFEST.SOURCE_LABEL')"
:placeholder="
t('CAPTAIN.CUSTOM_TOOLS.INSTALL_MANIFEST.SOURCE_PLACEHOLDER')
"
:message="t('CAPTAIN.CUSTOM_TOOLS.INSTALL_MANIFEST.SOURCE_HELP')"
class="[&_input]:font-mono"
autofocus
/>
<div v-if="!preview" class="flex justify-center py-8">
<Spinner />
</div>
<div
@@ -260,11 +239,7 @@ defineExpose({ open });
:key="tool.id"
class="flex items-center gap-2 text-sm text-n-slate-12"
>
<span
class="px-1.5 py-0.5 text-xs font-mono rounded bg-n-alpha-2 text-n-slate-11 w-16 text-center shrink-0"
>
{{ tool.http_method }}
</span>
<i class="i-lucide-wrench size-3.5 shrink-0 text-n-slate-10" />
<span class="truncate">{{ tool.title }}</span>
</li>
</ul>
@@ -316,31 +291,19 @@ defineExpose({ open });
type="button"
faded
slate
:label="
preview
? t('CAPTAIN.CUSTOM_TOOLS.INSTALL_MANIFEST.BACK')
: t('CAPTAIN.FORM.CANCEL')
"
:label="t('CAPTAIN.FORM.CANCEL')"
class="w-full"
:disabled="isInstalling"
@click="goBack"
@click="close"
/>
<Button
v-if="!preview"
type="button"
:label="t('CAPTAIN.CUSTOM_TOOLS.INSTALL_MANIFEST.CONTINUE')"
class="w-full"
:is-loading="isLoadingPreview"
:disabled="!source.trim() || isLoadingPreview"
@click="loadPreview"
/>
<Button
v-else
type="button"
:label="installLabel"
class="w-full"
:is-loading="isInstalling"
:disabled="isInstalled || hasMissingRequiredValues || isInstalling"
:disabled="
!preview || isInstalled || hasMissingRequiredValues || isInstalling
"
@click="install"
/>
</div>

View File

@@ -0,0 +1,60 @@
<script setup>
import { useI18n } from 'vue-i18n';
import ToolsetIdentity from './ToolsetIdentity.vue';
import ToolsetInstallBadge from './ToolsetInstallBadge.vue';
defineProps({
toolset: {
type: Object,
required: true,
},
installStatus: {
type: String,
default: null,
},
});
const emit = defineEmits(['click']);
const { t } = useI18n();
</script>
<template>
<button
type="button"
class="flex flex-col w-full h-full gap-3 p-4 text-start rounded-xl outline outline-1 outline-n-weak bg-n-solid-1 hover:bg-n-alpha-1"
@click="emit('click')"
>
<div class="flex items-start w-full gap-3">
<ToolsetIdentity :toolset="toolset" class="flex-1" />
<ToolsetInstallBadge v-if="installStatus" :status="installStatus" />
</div>
<p class="mb-0 text-sm text-n-slate-11 line-clamp-2">
{{ toolset.description }}
</p>
<!-- Only the category shrinks, so a long one truncates instead of wrapping the counts -->
<div
class="flex items-center w-full min-w-0 gap-2 mt-auto text-xs text-n-slate-11"
>
<span class="truncate" :title="toolset.category">
{{ toolset.category }}
</span>
<span class="rounded-full size-1 shrink-0 bg-n-slate-8" />
<span class="shrink-0 whitespace-nowrap">
{{
t('CAPTAIN.CUSTOM_TOOLS.CATALOG.TOOL_COUNT', {
n: toolset.tool_count,
})
}}
</span>
<span class="rounded-full size-1 shrink-0 bg-n-slate-8" />
<span class="shrink-0 whitespace-nowrap">
{{
t('CAPTAIN.CUSTOM_TOOLS.CATALOG.VERSION', {
version: toolset.version,
})
}}
</span>
</div>
</button>
</template>

View File

@@ -0,0 +1,246 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAbortableRequest } from 'dashboard/composables/useAbortableRequest';
import MessageFormatter from 'shared/helpers/MessageFormatter';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { fetchToolsetDetails } from 'dashboard/api/captain/toolsCatalog';
import SidePanel from 'dashboard/components-next/side-panel/SidePanel.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Policy from 'dashboard/components/policy.vue';
import ToolsetIdentity from './ToolsetIdentity.vue';
import ToolsetInstallBadge from './ToolsetInstallBadge.vue';
const emit = defineEmits(['install']);
const { t } = useI18n();
const {
run: runDetailsRequest,
abort: abortDetails,
isPending: isLoading,
} = useAbortableRequest();
const panelRef = ref(null);
// The list entry renders the header right away while the details load
const toolset = ref(null);
const details = ref(null);
const hasError = ref(false);
const installStatus = ref(null);
// An installed toolset can be reinstalled to restore deleted tools, or updated when the catalog has a newer version
const installAction = computed(() => {
if (installStatus.value === 'installed') {
return {
label: t('CAPTAIN.CUSTOM_TOOLS.INSTALL_MANIFEST.REINSTALL'),
icon: 'i-lucide-refresh-cw',
};
}
if (installStatus.value === 'update') {
return {
label: t('CAPTAIN.CUSTOM_TOOLS.INSTALL_MANIFEST.UPDATE'),
icon: 'i-lucide-arrow-up-circle',
};
}
return {
label: t('CAPTAIN.CUSTOM_TOOLS.INSTALL_MANIFEST.INSTALL'),
icon: 'i-lucide-download',
};
});
// Images are dropped: the panel has no use for them, and relative image paths break the shared formatter
const readmeHtml = computed(() => {
const formatter = new MessageFormatter(details.value.readme);
formatter.disableImageRendering();
return formatter.formattedMessage;
});
const configurationFields = computed(() => [
...(details.value?.inputs || []),
...(details.value?.secrets || []).map(field => ({ ...field, secret: true })),
]);
const updatedAt = computed(() =>
dynamicTime(Date.parse(toolset.value.updated_at) / 1000)
);
const loadDetails = async () => {
hasError.value = false;
try {
const response = await runDetailsRequest(signal =>
fetchToolsetDetails(toolset.value.detail_url, { signal })
);
if (response) details.value = response;
} catch {
hasError.value = true;
}
};
const open = (selectedToolset, selectedInstallStatus = null) => {
abortDetails();
toolset.value = selectedToolset;
installStatus.value = selectedInstallStatus;
details.value = null;
panelRef.value.open();
loadDetails();
};
const close = () => panelRef.value.close();
const install = () => {
close();
emit('install', toolset.value);
};
defineExpose({ open });
</script>
<template>
<SidePanel ref="panelRef" width="2xl" @close="abortDetails">
<template v-if="toolset" #header>
<ToolsetIdentity :toolset="toolset">
<ToolsetInstallBadge v-if="installStatus" :status="installStatus" />
</ToolsetIdentity>
</template>
<div v-if="toolset" class="flex flex-col gap-6">
<div class="flex flex-col gap-3">
<p class="mb-0 text-sm text-n-slate-12">{{ toolset.description }}</p>
<div
class="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-n-slate-11"
>
<span>{{ toolset.category }}</span>
<span class="rounded-full size-1 bg-n-slate-8" />
<span>
{{
t('CAPTAIN.CUSTOM_TOOLS.CATALOG.VERSION', {
version: toolset.version,
})
}}
</span>
<span class="rounded-full size-1 bg-n-slate-8" />
<span>
{{ t('CAPTAIN.CUSTOM_TOOLS.CATALOG.UPDATED', { time: updatedAt }) }}
</span>
</div>
<div v-if="details" class="flex flex-wrap gap-4 text-xs">
<a
:href="details.repository_url"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-n-blue-11 hover:underline"
>
{{ t('CAPTAIN.CUSTOM_TOOLS.CATALOG.REPOSITORY') }}
<i class="i-lucide-external-link size-3" />
</a>
<a
:href="details.manifest_url"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-n-blue-11 hover:underline"
>
{{ t('CAPTAIN.CUSTOM_TOOLS.CATALOG.MANIFEST') }}
<i class="i-lucide-external-link size-3" />
</a>
</div>
</div>
<div v-if="isLoading" class="flex justify-center py-10">
<Spinner />
</div>
<p v-else-if="hasError" class="mb-0 text-sm text-n-ruby-11">
{{ t('CAPTAIN.CUSTOM_TOOLS.CATALOG.DETAILS_ERROR') }}
</p>
<template v-else-if="details">
<section class="flex flex-col gap-2">
<h4 class="text-sm font-medium text-n-slate-12">
{{
t('CAPTAIN.CUSTOM_TOOLS.CATALOG.TOOL_COUNT', {
n: details.tools.length,
})
}}
</h4>
<ul
class="m-0 list-none divide-y rounded-xl outline outline-1 outline-n-weak divide-n-weak"
>
<li
v-for="tool in details.tools"
:key="tool.id"
class="flex items-start gap-3 px-4 py-3"
>
<span
class="flex items-center justify-center rounded-lg size-7 shrink-0 bg-n-alpha-2 text-n-slate-11"
>
<i class="i-lucide-wrench size-3.5" />
</span>
<div class="flex flex-col min-w-0 gap-0.5">
<span class="text-sm font-medium text-n-slate-12">
{{ tool.title }}
</span>
<span class="text-xs text-n-slate-11">
{{ tool.description }}
</span>
</div>
</li>
</ul>
</section>
<section v-if="configurationFields.length" class="flex flex-col gap-2">
<h4 class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.CUSTOM_TOOLS.CATALOG.CONFIGURATION') }}
</h4>
<ul class="flex flex-col gap-1.5 m-0 list-none">
<li
v-for="field in configurationFields"
:key="`${field.secret}-${field.id}`"
class="flex items-center gap-2 text-sm text-n-slate-12"
>
<i
:class="
field.secret
? 'i-lucide-key-round'
: 'i-lucide-sliders-horizontal'
"
class="size-3.5 shrink-0 text-n-slate-11"
/>
<span class="truncate">{{ field.label }}</span>
<span v-if="field.required" class="text-xs text-n-slate-10">
{{ t('CAPTAIN.CUSTOM_TOOLS.CATALOG.REQUIRED') }}
</span>
</li>
</ul>
</section>
<section v-if="details.readme" class="pt-6 border-t border-n-weak">
<div
v-dompurify-html="readmeHtml"
class="prose prose-sm max-w-none break-words text-n-slate-12 prose-headings:text-n-slate-12 prose-strong:text-n-slate-12 prose-code:text-n-slate-12 prose-a:text-n-blue-11 prose-p:my-2 prose-headings:mb-2 prose-headings:mt-4 prose-ul:my-2 prose-ol:my-2 prose-li:my-1"
/>
</section>
</template>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<Button
type="button"
faded
slate
:label="t('GENERAL.CLOSE')"
@click="close"
/>
<Policy :permissions="['administrator']">
<Button
type="button"
:icon="installAction.icon"
:label="installAction.label"
@click="install"
/>
</Policy>
</div>
</template>
</SidePanel>
</template>

View File

@@ -0,0 +1,42 @@
<script setup>
defineProps({
toolset: {
type: Object,
required: true,
},
});
</script>
<template>
<div class="flex items-center w-full min-w-0 gap-3">
<div
class="flex items-center justify-center overflow-hidden rounded-lg size-10 shrink-0 bg-n-alpha-2"
>
<template v-if="toolset.logo_url">
<img
:src="toolset.logo_url"
alt=""
class="object-contain size-full dark:hidden"
/>
<img
:src="toolset.logo_dark_url || toolset.logo_url"
alt=""
class="hidden object-contain size-full dark:block"
/>
</template>
<i v-else class="i-lucide-blocks size-5 text-n-slate-11" />
</div>
<div class="flex flex-col min-w-0">
<!-- The slot sits on the name line, for tags like the install status -->
<div class="flex items-center min-w-0 gap-2">
<span class="text-sm font-medium truncate text-n-slate-12">
{{ toolset.name }}
</span>
<slot />
</div>
<span class="text-xs truncate text-n-slate-11">
{{ toolset.author }}
</span>
</div>
</div>
</template>

View File

@@ -0,0 +1,37 @@
<script setup>
import { useI18n } from 'vue-i18n';
defineProps({
// 'installed' or 'update', from the catalog page's installed toolsets
status: {
type: String,
required: true,
validator: value => ['installed', 'update'].includes(value),
},
});
const { t } = useI18n();
</script>
<template>
<span
class="inline-flex items-center gap-1 px-1.5 py-0.5 text-xs rounded-md shrink-0"
:class="
status === 'update'
? 'bg-n-amber-3 text-n-amber-11'
: 'bg-n-teal-3 text-n-teal-11'
"
>
<i
:class="
status === 'update' ? 'i-lucide-arrow-up-circle' : 'i-lucide-check'
"
class="size-3"
/>
{{
status === 'update'
? t('CAPTAIN.CUSTOM_TOOLS.CATALOG.UPDATE_AVAILABLE')
: t('CAPTAIN.CUSTOM_TOOLS.CATALOG.INSTALLED')
}}
</span>
</template>

View File

@@ -0,0 +1,109 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import InstallManifestDialog from './InstallManifestDialog.vue';
const props = defineProps({
source: {
type: String,
required: true,
},
// The assistant whose page is open, preselected in the picker
assistantId: {
type: [String, Number],
required: true,
},
});
const emit = defineEmits(['installed', 'done']);
const { t } = useI18n();
const store = useStore();
const assistants = useMapGetter('captainAssistants/getRecords');
const pickerRef = ref(null);
const installDialogRef = ref(null);
const pickedAssistantId = ref(Number(props.assistantId));
// Set once an assistant is chosen, which mounts the install dialog for it
const installAssistantId = ref(null);
const assistantOptions = computed(() =>
assistants.value.map(assistant => ({
value: assistant.id,
label: assistant.name,
}))
);
const install = async assistantId => {
installAssistantId.value = assistantId;
await nextTick();
installDialogRef.value.open(props.source);
};
const confirmAssistant = () => {
install(pickedAssistantId.value);
pickerRef.value.close();
};
// Cancelling keeps the assistant that was open, not an unconfirmed pick
const onPickerClose = () => {
if (!installAssistantId.value) emit('done', Number(props.assistantId));
};
// Leaving the tools page while assistants load must not open dialogs that are already gone
let isUnmounted = false;
onBeforeUnmount(() => {
isUnmounted = true;
});
onMounted(async () => {
try {
await store.dispatch('captainAssistants/get');
} catch {
if (isUnmounted) return;
// Finishing clears the install query, so the tools page doesn't keep a flow that never started
useAlert(t('CAPTAIN.CUSTOM_TOOLS.INSTALL_LINK.LOAD_ERROR'));
emit('done', Number(props.assistantId));
return;
}
if (isUnmounted) return;
if (assistants.value.length > 1) {
pickerRef.value.open();
} else {
install(Number(props.assistantId));
}
});
</script>
<template>
<Dialog
ref="pickerRef"
:title="t('CAPTAIN.CUSTOM_TOOLS.INSTALL_LINK.TITLE')"
:description="t('CAPTAIN.CUSTOM_TOOLS.INSTALL_LINK.DESCRIPTION')"
:confirm-button-label="t('CAPTAIN.CUSTOM_TOOLS.INSTALL_MANIFEST.CONTINUE')"
:disable-confirm-button="!pickedAssistantId"
@confirm="confirmAssistant"
@close="onPickerClose"
>
<ComboBox
v-model="pickedAssistantId"
:options="assistantOptions"
:placeholder="t('CAPTAIN.CUSTOM_TOOLS.INSTALL_LINK.PLACEHOLDER')"
class="[&>div>button]:bg-n-alpha-black2"
/>
</Dialog>
<InstallManifestDialog
v-if="installAssistantId"
ref="installDialogRef"
:assistant-id="installAssistantId"
@installed="emit('installed', installAssistantId)"
@close="emit('done', installAssistantId)"
/>
</template>

View File

@@ -568,7 +568,7 @@ const menuItems = computed(() => {
{
name: 'Tools',
label: t('SIDEBAR.CAPTAIN_TOOLS'),
activeOn: ['captain_tools_index'],
activeOn: ['captain_tools_index', 'captain_tools_explore'],
to: accountScopedRoute('captain_assistants_index', {
navigationPath: 'captain_tools_index',
}),

View File

@@ -0,0 +1,69 @@
import { mount } from '@vue/test-utils';
import { afterEach, describe, expect, it, vi } from 'vitest';
import CopilotEditor from './CopilotEditor.vue';
let view = null;
// The component keeps its EditorView in a module local, so subclass it to
// reach the DOM event handlers it registers.
vi.mock('@chatwoot/prosemirror-schema', async importOriginal => {
const actual = await importOriginal();
class TrackedEditorView extends actual.EditorView {
constructor(...args) {
super(...args);
view = this;
}
}
return { ...actual, EditorView: TrackedEditorView };
});
// jsdom has no layout, and ProseMirror measures the caret through Range rects.
const zeroRect = { top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0 };
Range.prototype.getClientRects = () => [zeroRect];
Range.prototype.getBoundingClientRect = () => zeroRect;
Element.prototype.scrollIntoView = () => {};
let wrapper = null;
afterEach(() => {
wrapper?.unmount();
wrapper = null;
});
const mountEditor = () => {
wrapper = mount(CopilotEditor, {
props: { modelValue: 'make it shorter', autofocus: false },
attachTo: document.body,
});
};
const editorKeydown = init => {
const event = new KeyboardEvent('keydown', {
key: 'Enter',
code: 'Enter',
cancelable: true,
...init,
});
view.someProp('handleDOMEvents', handlers => handlers.keydown?.(view, event));
return event;
};
describe('CopilotEditor', () => {
it('sends on a normal Enter', () => {
mountEditor();
editorKeydown({});
expect(wrapper.emitted('send')).toHaveLength(1);
});
it('does not send while an IME composition is active', () => {
mountEditor();
editorKeydown({ isComposing: true });
expect(wrapper.emitted('send')).toBeUndefined();
});
it('does not send when WebKit reports the composition only through keyCode 229', () => {
mountEditor();
editorKeydown({ keyCode: 229 });
expect(wrapper.emitted('send')).toBeUndefined();
});
});

View File

@@ -21,6 +21,7 @@ import {
} from '@chatwoot/prosemirror-schema';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import { isComposing } from 'shared/helpers/KeyboardHelpers';
import NextButton from 'dashboard/components-next/button/Button.vue';
@@ -132,7 +133,7 @@ function onKeydown(view, event) {
// Handle Enter key to send message (Shift+Enter for new line)
// Skip if IME composition is active (CJK character confirmation)
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
if (event.key === 'Enter' && !event.shiftKey && !isComposing(event)) {
event.preventDefault();
handleSubmit();
return true; // Prevent ProseMirror's default Enter handling

View File

@@ -0,0 +1,90 @@
import { mount } from '@vue/test-utils';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createStore } from 'vuex';
import Editor from './Editor.vue';
vi.mock('vue-router', () => ({
useRoute: () => ({ params: { accountId: 1 } }),
useRouter: () => ({ push: vi.fn() }),
}));
let view = null;
// The component keeps its EditorView in a module local, so subclass it to
// reach the DOM event handlers it registers.
vi.mock('@chatwoot/prosemirror-schema', async importOriginal => {
const actual = await importOriginal();
class TrackedEditorView extends actual.EditorView {
constructor(...args) {
super(...args);
view = this;
}
}
return { ...actual, EditorView: TrackedEditorView };
});
// jsdom has no layout, and ProseMirror measures the caret through Range rects.
const zeroRect = { top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0 };
Range.prototype.getClientRects = () => [zeroRect];
Range.prototype.getBoundingClientRect = () => zeroRect;
Element.prototype.scrollIntoView = () => {};
const store = createStore({
getters: {
getUISettings: () => ({ editor_message_key: 'enter' }),
getSelectedChat: () => ({}),
'globalConfig/get': () => ({}),
'globalConfig/isOnChatwootCloud': () => false,
'accounts/getAccount': () => () => ({}),
'accounts/getUIFlags': () => ({}),
'accounts/isFeatureEnabledonAccount': () => () => false,
'draftMessages/getReplyEditorMode': () => 'reply',
},
});
let wrapper = null;
afterEach(() => {
wrapper?.unmount();
wrapper = null;
});
const mountEditor = () => {
wrapper = mount(Editor, {
props: { modelValue: 'thanks for reaching ou' },
global: { plugins: [store] },
attachTo: document.body,
});
};
// Runs the editor's own keydown handler, without ProseMirror's keymap, so the
// assertion is about what this component does with the event.
const editorKeydown = init => {
const event = new KeyboardEvent('keydown', {
key: 'Enter',
code: 'Enter',
cancelable: true,
...init,
});
view.someProp('handleDOMEvents', handlers => handlers.keydown?.(view, event));
return event;
};
describe('Editor', () => {
describe('Enter with enter-to-send enabled', () => {
it('prevents the line break on a normal Enter', () => {
mountEditor();
expect(editorKeydown({}).defaultPrevented).toBe(true);
});
it('leaves Enter alone while an IME composition is active', () => {
mountEditor();
expect(editorKeydown({ isComposing: true }).defaultPrevented).toBe(false);
});
it('leaves Enter alone when WebKit reports the composition only through keyCode 229', () => {
mountEditor();
expect(editorKeydown({ keyCode: 229 }).defaultPrevented).toBe(false);
});
});
});

View File

@@ -68,6 +68,7 @@ import {
import {
hasPressedEnterAndNotCmdOrShift,
hasPressedCommandAndEnter,
isComposing,
isEscape,
} from 'shared/helpers/KeyboardHelpers';
import { createTypingIndicator } from '@chatwoot/utils';
@@ -800,6 +801,7 @@ function handleLineBreakWhenCmdAndEnterToSendEnabled(event) {
}
function onKeydown(event) {
if (isComposing(event)) return false;
if (isEscape(event)) {
collapseSelection(editorView);
return true;

View File

@@ -16,7 +16,6 @@ import {
} from '@vueuse/core';
// composable
import { useTrack } from 'dashboard/composables';
import { useLabelSuggestions } from 'dashboard/composables/useLabelSuggestions';
import { useCampaignHistory } from 'dashboard/composables/useCampaignHistory';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
@@ -25,7 +24,6 @@ import { CONTACT_CONVERSATION_NAVIGATION } from 'dashboard/composables/useContac
// components
import ReplyBox from './ReplyBox.vue';
import MessageList from 'next/message/MessageList.vue';
import ConversationLabelSuggestion from './conversation/LabelSuggestion.vue';
import ContactConversationLink from './ContactConversationLink.vue';
import Banner from 'dashboard/components/ui/Banner.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
@@ -49,7 +47,6 @@ import {
getUnreadScrollTop,
} from './helpers/campaignScrollAnchor';
import { calculateScrollTop } from './helpers/scrollTopCalculationHelper';
import { LocalStorage } from 'shared/helpers/localStorage';
import {
filterDuplicateSourceMessages,
getReadMessages,
@@ -60,10 +57,7 @@ import {
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { CMD_AI_ASSIST } from 'dashboard/helper/commandbar/events';
import { REPLY_POLICY } from 'shared/constants/links';
import wootConstants, {
META_RESTRICTION_STATUS_URL,
} from 'dashboard/constants/globals';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { META_RESTRICTION_STATUS_URL } from 'dashboard/constants/globals';
import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage';
import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
@@ -84,7 +78,6 @@ export default {
MessageList,
ReplyBox,
Banner,
ConversationLabelSuggestion,
ContactConversationLink,
Spinner,
NextButton,
@@ -102,12 +95,6 @@ export default {
const { height: containerHeight } = useElementSize(messagesViewRef);
const { height: topBannerHeight } = useElementSize(topBannerRef);
const {
captainTasksEnabled,
isLabelSuggestionFeatureEnabled,
getLabelSuggestions,
} = useLabelSuggestions();
const {
olderConversation,
newerConversation,
@@ -179,9 +166,6 @@ export default {
return {
...useCampaignHistory(),
captainTasksEnabled,
getLabelSuggestions,
isLabelSuggestionFeatureEnabled,
olderConversation,
newerConversation,
openConversation,
@@ -211,8 +195,6 @@ export default {
conversationPanel: null,
hasUserScrolled: false,
isProgrammaticScroll: false,
messageSentSinceOpened: false,
labelSuggestions: [],
};
},
@@ -221,20 +203,8 @@ export default {
currentChat: 'getSelectedChat',
currentUserId: 'getCurrentUserID',
listLoadingStatus: 'getAllMessagesLoaded',
currentAccountId: 'getCurrentAccountId',
isMetaMessageSendingDisabled: 'globalConfig/isMetaMessageSendingDisabled',
}),
isOpen() {
return this.currentChat?.status === wootConstants.STATUS_TYPE.OPEN;
},
shouldShowLabelSuggestions() {
return (
this.isOpen &&
this.captainTasksEnabled &&
this.isLabelSuggestionFeatureEnabled &&
!this.messageSentSinceOpened
);
},
inboxId() {
return this.currentChat.inbox_id;
},
@@ -414,8 +384,6 @@ export default {
return;
}
this.fetchAllAttachmentsFromCurrentChat();
this.fetchSuggestions();
this.messageSentSinceOpened = false;
this.resetReplyEditorHeight();
},
// The link is appended once the neighbours arrive, below an already scrolled list.
@@ -431,11 +399,6 @@ export default {
created() {
emitter.on(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
// when a message is sent we set the flag to true this hides the label suggestions,
// until the chat is changed and the flag is reset in the watch for currentChat
emitter.on(BUS_EVENTS.MESSAGE_SENT, () => {
this.messageSentSinceOpened = true;
});
// Anything that wants to write must bring the folded editor back first.
emitter.on(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.showReplyBox);
emitter.on(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, this.showReplyBox);
@@ -445,7 +408,6 @@ export default {
mounted() {
this.addScrollListener();
this.fetchAllAttachmentsFromCurrentChat();
this.fetchSuggestions();
},
unmounted() {
@@ -454,47 +416,6 @@ export default {
},
methods: {
async fetchSuggestions() {
// start empty, this ensures that the label suggestions are not shown
this.labelSuggestions = [];
if (this.isLabelSuggestionDismissed()) {
return;
}
// Early exit if conversation already has labels - no need to suggest more
const existingLabels = this.currentChat?.labels || [];
if (existingLabels.length > 0) return;
if (!this.captainTasksEnabled || !this.isLabelSuggestionFeatureEnabled) {
return;
}
this.labelSuggestions = await this.getLabelSuggestions();
// once the labels are fetched, we need to scroll to bottom
// but we need to wait for the DOM to be updated
// so we use the nextTick method
this.$nextTick(() => {
// this param is added to route, telling the UI to navigate to the message
// it is triggered by the SCROLL_TO_MESSAGE method
// see setActiveChat on ConversationView.vue for more info
const { messageId } = this.$route.query;
// only trigger the scroll to bottom if the user has not scrolled
// and there's no active messageId that is selected in view
if (!messageId && !this.hasUserScrolled) {
this.scrollToBottom();
}
});
},
isLabelSuggestionDismissed() {
return LocalStorage.getFlag(
LOCAL_STORAGE_KEYS.DISMISSED_LABEL_SUGGESTIONS,
this.currentAccountId,
this.currentChat.id
);
},
fetchAllAttachmentsFromCurrentChat() {
this.$store.dispatch('fetchAllAttachments', this.currentChat.id);
},
@@ -529,12 +450,6 @@ export default {
},
scrollToBottom() {
this.isProgrammaticScroll = true;
let relevantMessages = [];
// label suggestions are not part of the messages list
// so we need to handle them separately
let labelSuggestions =
this.conversationPanel.querySelector('.label-suggestion');
// if there are unread messages, scroll to the first unread message
if (this.unreadMessageCount > 0) {
@@ -547,21 +462,12 @@ export default {
return;
}
}
if (labelSuggestions) {
// when scrolling to the bottom, the label suggestions is below the last message
// so we scroll there if there are no unread messages
// Unread messages always take the highest priority
relevantMessages = [labelSuggestions];
} else {
// if there are no unread messages or label suggestion, scroll to the last message
// capturing last message from the messages list
relevantMessages = getTimelineEntries(this.conversationPanel).slice(-1);
}
// otherwise scroll to the last message in the timeline
this.conversationPanel.scrollTop = calculateScrollTop(
this.conversationPanel.scrollHeight,
this.$el.scrollHeight,
relevantMessages
getTimelineEntries(this.conversationPanel).slice(-1)
);
},
setScrollParams() {
@@ -715,12 +621,6 @@ export default {
</li>
</template>
<template #after>
<ConversationLabelSuggestion
v-if="shouldShowLabelSuggestions"
:suggested-labels="labelSuggestions"
:chat-labels="currentChat.labels"
:conversation-id="currentChat.id"
/>
<ContactConversationLink
v-if="newerConversation"
direction="newer"

View File

@@ -27,6 +27,7 @@ import { getUndefinedVariablesInMessage } from '@chatwoot/utils';
import WhatsappTemplates from './WhatsappTemplates/Modal.vue';
import ContentTemplates from './ContentTemplates/ContentTemplatesModal.vue';
import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper';
import { isComposing } from 'shared/helpers/KeyboardHelpers';
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
import { trimContent, debounce, getRecipients } from '@chatwoot/utils';
import wootConstants from 'dashboard/constants/globals';
@@ -113,6 +114,7 @@ export default {
},
Enter: {
action: e => {
if (isComposing(e)) return;
if (proxy.isAValidEvent('enter')) {
proxy.onSendReply();
e.preventDefault();
@@ -121,7 +123,8 @@ export default {
allowOnFocusedInput: true,
},
'$mod+Enter': {
action: () => {
action: e => {
if (isComposing(e)) return;
if (copilot.isActive.value && proxy.isFocused) {
proxy.onSubmitCopilotReply();
} else if (proxy.isAValidEvent('cmd_enter')) {

View File

@@ -0,0 +1,170 @@
<script setup>
import { ref, watch } from 'vue';
import { dateFormat } from 'shared/helpers/timeHelper';
import { useAbortableRequest } from 'dashboard/composables/useAbortableRequest';
import {
formatStripeAmount,
STRIPE_STATUS_COLORS,
} from 'dashboard/helper/stripeHelper';
import StripeAPI from 'dashboard/api/integrations/stripe';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Label from 'dashboard/components-next/label/Label.vue';
import StripeSubscription from './StripeSubscription.vue';
const props = defineProps({
conversationId: { type: [Number, String], required: true },
contactEmail: { type: String, default: '' },
});
const { run, isPending } = useAbortableRequest();
const summary = ref(null);
const customerId = ref('');
const error = ref(false);
const load = async () => {
error.value = false;
summary.value = null;
try {
const response = await run(signal =>
StripeAPI.customer(
props.conversationId,
customerId.value || undefined,
signal
)
);
if (response) summary.value = response.data;
} catch {
error.value = true;
}
};
watch(
[() => props.conversationId, () => props.contactEmail],
() => {
customerId.value = '';
load();
},
{ immediate: true }
);
</script>
<template>
<div class="flex flex-col gap-4 px-4 py-3 text-sm text-n-slate-12">
<Spinner v-if="isPending" class="mx-auto" />
<p v-else-if="error" role="alert" class="m-0 text-n-ruby-11">
{{ $t('STRIPE_INTEGRATION.ERROR') }}
</p>
<template v-else-if="summary">
<p v-if="summary.missing_email" class="m-0 text-n-slate-11">
{{ $t('STRIPE_INTEGRATION.NO_EMAIL') }}
</p>
<p v-else-if="!summary.customers.length" class="m-0 text-n-slate-11">
{{ $t('STRIPE_INTEGRATION.EMPTY') }}
</p>
<template v-else>
<label v-if="summary.customers.length > 1" class="flex flex-col gap-2">
{{ $t('STRIPE_INTEGRATION.SELECT') }}
<select v-model="customerId" class="!mb-0" @change="load">
<option value="" disabled>
{{ $t('STRIPE_INTEGRATION.SELECT') }}
</option>
<option
v-for="customer in summary.customers"
:key="customer.id"
:value="customer.id"
>
{{
$t('STRIPE_INTEGRATION.CUSTOMER_OPTION', {
name: customer.name || customer.email,
id: customer.id,
})
}}
</option>
</select>
</label>
<p
v-if="summary.has_more_customers"
class="m-0 text-xs text-n-slate-11"
>
{{ $t('STRIPE_INTEGRATION.MORE') }}
</p>
<template v-if="summary.customer">
<div class="flex items-center gap-3 min-w-0">
<span
class="flex items-center justify-center size-9 shrink-0 rounded-lg bg-n-alpha-2 text-n-slate-11"
>
<span class="i-lucide-user-round size-4" aria-hidden="true" />
</span>
<div
class="flex flex-col gap-0.5 min-w-0 text-xs text-n-slate-11"
:title="summary.customer.id"
>
<span class="text-sm font-medium truncate text-n-slate-12">
{{ summary.customer.name }}
</span>
<span class="truncate">{{ summary.customer.email }}</span>
<span v-if="summary.customer.phone">
{{ summary.customer.phone }}
</span>
</div>
</div>
<section class="flex flex-col gap-2 pt-3 border-t border-n-weak">
<h4 class="m-0 text-xs font-medium text-n-slate-11">
{{ $t('STRIPE_INTEGRATION.SUBSCRIPTIONS') }}
</h4>
<p
v-if="!summary.subscriptions.length"
class="m-0 text-xs text-n-slate-11"
>
{{ $t('STRIPE_INTEGRATION.NO_SUBSCRIPTIONS') }}
</p>
<StripeSubscription
v-for="subscription in summary.subscriptions"
:key="subscription.id"
:subscription="subscription"
/>
</section>
<section class="flex flex-col pt-3 border-t border-n-weak">
<h4 class="m-0 text-xs font-medium text-n-slate-11">
{{ $t('STRIPE_INTEGRATION.INVOICES') }}
</h4>
<p
v-if="!summary.invoices.length"
class="mt-2 mb-0 text-xs text-n-slate-11"
>
{{ $t('STRIPE_INTEGRATION.NO_INVOICES') }}
</p>
<div
v-for="invoice in summary.invoices"
:key="invoice.id"
class="flex items-center justify-between gap-3 py-2.5 border-b border-n-weak last:border-b-0 last:pb-0"
>
<div class="flex flex-col gap-1 min-w-0">
<span
class="text-sm font-medium truncate"
:title="invoice.number || invoice.id"
>
{{ invoice.number || invoice.id }}
</span>
<span class="text-xs text-n-slate-11">
{{ dateFormat(invoice.created) }}
</span>
</div>
<div class="flex flex-col items-end gap-1 shrink-0">
<span class="text-sm font-medium tabular-nums">
{{ formatStripeAmount(invoice.total, invoice.currency) }}
</span>
<Label
:label="invoice.status"
:color="STRIPE_STATUS_COLORS[invoice.status] || 'slate'"
compact
class="capitalize"
/>
</div>
</div>
</section>
</template>
</template>
</template>
</div>
</template>

View File

@@ -0,0 +1,217 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useClipboard } from '@vueuse/core';
import { dateFormat } from 'shared/helpers/timeHelper';
import {
formatStripeAmount,
STRIPE_STATUS_COLORS,
} from 'dashboard/helper/stripeHelper';
import SidePanel from 'dashboard/components-next/side-panel/SidePanel.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Label from 'dashboard/components-next/label/Label.vue';
const props = defineProps({ subscription: { type: Object, required: true } });
const { t } = useI18n();
const { copy, copied } = useClipboard();
const panel = ref(null);
const title = computed(
() =>
props.subscription.items
?.map(item => item.name)
.filter(Boolean)
.join(', ') || t('STRIPE_INTEGRATION.SUBSCRIPTION')
);
const statusLabel = computed(() =>
props.subscription.status.replaceAll('_', ' ')
);
const statusColor = computed(
() => STRIPE_STATUS_COLORS[props.subscription.status] || 'slate'
);
const isTrialing = computed(() => props.subscription.status === 'trialing');
const isRenewing = computed(
() =>
['active', 'trialing'].includes(props.subscription.status) &&
!props.subscription.cancel_at_period_end &&
!props.subscription.cancel_at
);
const trialMessage = computed(() =>
isTrialing.value && props.subscription.trial_end
? t('STRIPE_INTEGRATION.TRIAL_ENDS', {
date: dateFormat(props.subscription.trial_end),
})
: ''
);
const cancellationMessage = computed(() => {
if (props.subscription.status === 'canceled') return '';
if (props.subscription.cancel_at)
return t('STRIPE_INTEGRATION.CANCELS', {
date: dateFormat(props.subscription.cancel_at),
});
return props.subscription.cancel_at_period_end
? t('STRIPE_INTEGRATION.CANCELS_AT_PERIOD_END')
: '';
});
const periodEndLabel = timestamp =>
isRenewing.value
? t('STRIPE_INTEGRATION.RENEWS', { date: dateFormat(timestamp) })
: t('STRIPE_INTEGRATION.PERIOD_END', { date: dateFormat(timestamp) });
// The card only has room for one period, so it is shown for single item subscriptions
const cardPeriodEnd = computed(() => {
const [item, ...otherItems] = props.subscription.items || [];
if (isTrialing.value || otherItems.length || !item?.current_period_end)
return '';
return periodEndLabel(item.current_period_end);
});
const priceLabel = item => {
if (
item.unit_amount == null ||
item.billing_scheme === 'tiered' ||
item.recurring?.usage_type === 'metered'
)
return t('STRIPE_INTEGRATION.VARIABLE_PRICE');
const amount = formatStripeAmount(item.unit_amount, item.currency, {
preservePrecision: true,
});
if (!item.recurring) return amount;
const count = item.recurring.interval_count;
return t('STRIPE_INTEGRATION.PRICE_INTERVAL', {
amount,
interval: {
day: t('STRIPE_INTEGRATION.INTERVAL.day', count),
week: t('STRIPE_INTEGRATION.INTERVAL.week', count),
month: t('STRIPE_INTEGRATION.INTERVAL.month', count),
year: t('STRIPE_INTEGRATION.INTERVAL.year', count),
}[item.recurring.interval],
});
};
</script>
<template>
<button
type="button"
class="flex items-center gap-2 w-full rounded-lg bg-n-alpha-1 p-3 text-start hover:bg-n-alpha-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-n-brand"
:aria-label="$t('STRIPE_INTEGRATION.VIEW_SUBSCRIPTION', { name: title })"
@click="panel.open()"
>
<span class="flex flex-col flex-1 gap-1 min-w-0 text-xs text-n-slate-11">
<span class="flex items-start justify-between gap-2">
<span class="text-sm font-medium break-words text-n-slate-12">
{{ title }}
</span>
<Label
:label="statusLabel"
:color="statusColor"
compact
class="capitalize"
/>
</span>
<span v-for="item in subscription.items" :key="item.id">
{{ priceLabel(item) }}
<span
v-if="item.quantity != null"
class="ms-2 ps-2 border-s border-n-strong"
>
{{ $t('STRIPE_INTEGRATION.QUANTITY', { count: item.quantity }) }}
</span>
</span>
<span v-if="trialMessage">{{ trialMessage }}</span>
<span v-if="cancellationMessage" class="text-n-amber-11">
{{ cancellationMessage }}
</span>
<span v-else-if="cardPeriodEnd">{{ cardPeriodEnd }}</span>
</span>
<span
class="i-lucide-chevron-right size-4 shrink-0 text-n-slate-10"
aria-hidden="true"
/>
</button>
<SidePanel ref="panel" :title="title" width="md">
<div class="flex flex-col gap-5 text-sm text-n-slate-12">
<div class="flex items-center gap-2">
<Label
:label="statusLabel"
:color="statusColor"
compact
class="capitalize"
/>
<span class="flex-1 min-w-0 font-mono text-xs truncate text-n-slate-11">
{{ subscription.id }}
</span>
<Button
:label="
copied
? $t('STRIPE_INTEGRATION.COPIED')
: $t('STRIPE_INTEGRATION.COPY_ID')
"
:icon="copied ? 'i-lucide-check' : 'i-lucide-copy'"
xs
ghost
slate
@click="copy(subscription.id)"
/>
</div>
<p v-if="trialMessage" class="m-0">{{ trialMessage }}</p>
<p
v-if="cancellationMessage"
class="flex items-center gap-2 px-3 py-2 m-0 rounded-lg bg-n-amber-2 text-n-amber-11"
>
<span class="i-lucide-calendar-x size-4 shrink-0" aria-hidden="true" />
{{ cancellationMessage }}
</p>
<p
v-if="subscription.status === 'canceled' && subscription.canceled_at"
class="m-0"
>
{{
$t('STRIPE_INTEGRATION.CANCELED_ON', {
date: dateFormat(subscription.canceled_at),
})
}}
</p>
<section
v-for="item in subscription.items"
:key="item.id"
class="flex flex-col gap-1.5 pt-4 border-t border-n-weak text-n-slate-11"
>
<h4
v-if="subscription.items.length > 1"
class="m-0 text-sm font-medium text-n-slate-12"
>
{{ item.name || $t('STRIPE_INTEGRATION.SUBSCRIPTION') }}
</h4>
<p class="m-0 text-n-slate-12">{{ priceLabel(item) }}</p>
<p v-if="item.quantity != null" class="m-0">
{{ $t('STRIPE_INTEGRATION.QUANTITY', { count: item.quantity }) }}
</p>
<p v-if="item.current_period_start" class="m-0">
{{
$t('STRIPE_INTEGRATION.PERIOD_START', {
date: dateFormat(item.current_period_start),
})
}}
</p>
<p v-if="item.current_period_end" class="m-0">
{{ periodEndLabel(item.current_period_end) }}
</p>
</section>
<p v-if="subscription.has_more_items" class="m-0 text-n-slate-11">
{{ $t('STRIPE_INTEGRATION.MORE_ITEMS') }}
</p>
<p class="pt-4 m-0 text-xs border-t border-n-weak text-n-slate-11">
{{ $t('STRIPE_INTEGRATION.PRICE_NOTE') }}
</p>
</div>
</SidePanel>
</template>

View File

@@ -1,277 +0,0 @@
<script>
// components
import NextButton from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import { useBranding } from 'shared/composables/useBranding';
// composables
import { useCaptain } from 'dashboard/composables/useCaptain';
import { useTrack } from 'dashboard/composables';
// store & api
import { mapGetters } from 'vuex';
// utils & constants
import { LocalStorage } from 'shared/helpers/localStorage';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { CAPTAIN_EVENTS } from '../../../../helper/AnalyticsHelper/events';
export default {
name: 'LabelSuggestion',
components: {
Avatar,
NextButton,
},
props: {
suggestedLabels: {
type: Array,
required: true,
},
chatLabels: {
type: Array,
required: false,
default: () => [],
},
},
setup() {
const { captainTasksEnabled } = useCaptain();
const { replaceInstallationName } = useBranding();
return { captainTasksEnabled, replaceInstallationName };
},
data() {
return {
isDismissed: false,
isHovered: false,
selectedLabels: [],
};
},
computed: {
...mapGetters({
allLabels: 'labels/getLabels',
currentAccountId: 'getCurrentAccountId',
currentChat: 'getSelectedChat',
}),
conversationId() {
return this.currentChat?.id;
},
labelTooltip() {
if (this.preparedLabels.length > 1) {
return this.$t('LABEL_MGMT.SUGGESTIONS.TOOLTIP.MULTIPLE_SUGGESTION');
}
return this.$t('LABEL_MGMT.SUGGESTIONS.TOOLTIP.SINGLE_SUGGESTION');
},
addButtonText() {
if (this.selectedLabels.length === 1) {
return this.$t('LABEL_MGMT.SUGGESTIONS.ADD_SELECTED_LABEL');
}
if (this.selectedLabels.length > 1) {
return this.$t('LABEL_MGMT.SUGGESTIONS.ADD_SELECTED_LABELS');
}
return this.$t('LABEL_MGMT.SUGGESTIONS.ADD_ALL_LABELS');
},
preparedLabels() {
return this.allLabels.filter(label =>
this.suggestedLabels.includes(label.title)
);
},
shouldShowSuggestions() {
if (this.isDismissed) return false;
if (!this.captainTasksEnabled) return false;
return this.preparedLabels.length && this.chatLabels.length === 0;
},
},
watch: {
conversationId: {
immediate: true,
handler() {
this.selectedLabels = [];
this.isDismissed = this.isConversationDismissed();
},
},
},
methods: {
pushOrAddLabel(label) {
if (this.preparedLabels.length === 1) {
this.addAllLabels();
return;
}
if (!this.selectedLabels.includes(label)) {
this.selectedLabels.push(label);
} else {
this.selectedLabels = this.selectedLabels.filter(l => l !== label);
}
},
dismissSuggestions() {
LocalStorage.setFlag(
LOCAL_STORAGE_KEYS.DISMISSED_LABEL_SUGGESTIONS,
this.currentAccountId,
this.conversationId
);
// dismiss this once the values are set
this.isDismissed = true;
this.trackLabelEvent(CAPTAIN_EVENTS.LABEL_SUGGESTION_DISMISSED);
},
isConversationDismissed() {
return LocalStorage.getFlag(
LOCAL_STORAGE_KEYS.DISMISSED_LABEL_SUGGESTIONS,
this.currentAccountId,
this.conversationId
);
},
addAllLabels() {
let labelsToAdd = this.selectedLabels;
if (!labelsToAdd.length) {
labelsToAdd = this.preparedLabels.map(label => label.title);
}
this.$store.dispatch('conversationLabels/update', {
conversationId: this.conversationId,
labels: labelsToAdd,
});
this.trackLabelEvent(CAPTAIN_EVENTS.LABEL_SUGGESTION_APPLIED);
},
trackLabelEvent(event) {
const payload = {
conversationId: this.conversationId,
account: this.currentAccountId,
suggestions: this.suggestedLabels,
labelsApplied: this.selectedLabels.length
? this.selectedLabels
: this.suggestedLabels,
};
useTrack(event, payload);
},
},
};
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<li
v-if="shouldShowSuggestions"
class="label-suggestion right list-none"
@mouseover="isHovered = true"
@mouseleave="isHovered = false"
>
<div class="wrap">
<div class="label-suggestion--container">
<h6 class="label-suggestion--title">
{{ $t('LABEL_MGMT.SUGGESTIONS.SUGGESTED_LABELS') }}
</h6>
<div class="label-suggestion--options">
<button
v-for="label in preparedLabels"
:key="label.title"
v-tooltip.top="{
content: selectedLabels.includes(label.title)
? $t('LABEL_MGMT.SUGGESTIONS.TOOLTIP.DESELECT')
: labelTooltip,
delay: { show: 600, hide: 0 },
hideOnClick: true,
}"
class="label-suggestion--option !px-0"
@click="pushOrAddLabel(label.title)"
>
<woot-label
variant="dashed"
v-bind="label"
:bg-color="selectedLabels.includes(label.title) ? '#2781F6' : ''"
/>
</button>
<NextButton
v-if="preparedLabels.length === 1"
v-tooltip.top="{
content: $t('LABEL_MGMT.SUGGESTIONS.TOOLTIP.DISMISS'),
delay: { show: 600, hide: 0 },
hideOnClick: true,
}"
faded
xs
icon="i-lucide-x"
class="flex-shrink-0"
:color="isHovered ? 'ruby' : 'blue'"
@click="dismissSuggestions"
/>
</div>
<div
v-if="preparedLabels.length > 1"
class="inline-flex items-center gap-1"
>
<NextButton
xs
icon="i-lucide-plus"
class="flex-shrink-0"
:variant="selectedLabels.length === 0 ? 'faded' : 'solid'"
:label="addButtonText"
@click="addAllLabels"
/>
<NextButton
v-tooltip.top="{
content: $t('LABEL_MGMT.SUGGESTIONS.TOOLTIP.DISMISS'),
delay: { show: 600, hide: 0 },
hideOnClick: true,
}"
faded
xs
icon="i-lucide-x"
class="flex-shrink-0"
:color="isHovered ? 'ruby' : 'blue'"
@click="dismissSuggestions"
/>
</div>
</div>
<div class="sender--info has-tooltip" data-original-title="null">
<Avatar
v-tooltip.top="{
content: replaceInstallationName(
$t('LABEL_MGMT.SUGGESTIONS.POWERED_BY')
),
delay: { show: 600, hide: 0 },
hideOnClick: true,
}"
:size="16"
name="chatwoot-ai"
icon-name="i-lucide-sparkles"
/>
</div>
</div>
</li>
</template>
<style scoped lang="scss">
.wrap {
display: flex;
}
.label-suggestion {
flex-direction: row;
justify-content: flex-end;
margin-top: 1rem;
.label-suggestion--container {
max-width: 300px;
}
.label-suggestion--options {
@apply gap-0.5 text-end flex items-center;
button.label-suggestion--option {
.label {
cursor: pointer;
margin-bottom: 0;
}
}
}
.label-suggestion--title {
@apply text-n-slate-11 mt-0.5 text-xxs;
}
}
</style>

View File

@@ -1,4 +1,4 @@
import { shallowMount } from '@vue/test-utils';
import { flushPromises, shallowMount } from '@vue/test-utils';
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
import { nextTick } from 'vue';
import { createStore } from 'vuex';
@@ -40,6 +40,7 @@ const buildStore = ({
drafts = {},
inboxes,
isMetaMessageSendingDisabled = false,
uiSettings = {},
}) =>
createStore({
state: {
@@ -68,7 +69,7 @@ const buildStore = ({
getCurrentUser: () => ({ id: 7, name: 'Agent', accounts: [] }),
getCurrentAccountId: () => 1,
getMessageSignature: () => '',
getUISettings: () => ({}),
getUISettings: () => uiSettings,
getLastEmailInSelectedChat: () => null,
'globalConfig/get': () => ({}),
'globalConfig/isMetaMessageSendingDisabled': () =>
@@ -95,6 +96,7 @@ const mountWith = ({
drafts,
inboxes,
isMetaMessageSendingDisabled,
uiSettings,
}) => {
const store = buildStore({
inbox,
@@ -103,6 +105,7 @@ const mountWith = ({
drafts,
inboxes,
isMetaMessageSendingDisabled,
uiSettings,
});
const wrapper = shallowMount(ReplyBox, {
global: {
@@ -447,4 +450,31 @@ describe('ReplyBox', () => {
);
});
});
describe('send shortcut', () => {
const pressEnter = init =>
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', ...init })
);
it('ignores Enter while an IME composition is active', async () => {
const { wrapper } = mountWith({
inbox: { channel_type: 'Channel::WebWidget' },
uiSettings: { editor_message_key: 'enter' },
});
await flushPromises();
wrapper.vm.isFocused = true;
const onSendReply = vi
.spyOn(wrapper.vm, 'onSendReply')
.mockImplementation(() => {});
pressEnter({ key: 'Process', isComposing: true });
expect(onSendReply).not.toHaveBeenCalled();
pressEnter({});
expect(onSendReply).toHaveBeenCalledTimes(1);
wrapper.unmount();
});
});
});

View File

@@ -0,0 +1,250 @@
import { mount, flushPromises } from '@vue/test-utils';
import StripeCustomer from '../StripeCustomer.vue';
import StripeAPI from 'dashboard/api/integrations/stripe';
vi.mock('dashboard/components-next/TeleportWithDirection.vue', () => ({
default: {
props: ['to'],
template: '<Teleport :to="to"><slot /></Teleport>',
},
}));
vi.mock('dashboard/api/integrations/stripe', () => ({
default: { customer: vi.fn() },
}));
describe('StripeCustomer', () => {
const customer = {
id: 'cus_alfred',
name: 'Alfred',
email: 'alfred@example.com',
};
const data = {
customers: [customer],
customer,
subscriptions: [
{
id: 'sub_test',
status: 'active',
items: [
{
id: 'si_test',
name: 'Support Pro',
quantity: 3,
unit_amount: 4900,
currency: 'usd',
recurring: { interval: 'month', interval_count: 1 },
current_period_start: 1788998400,
current_period_end: 1791590400,
},
],
},
],
invoices: [
{
id: 'in_test',
number: 'INV-001',
total: 1000,
currency: 'usd',
status: 'paid',
created: 1788998400,
},
],
};
let wrapper;
it.each(['active', 'canceled', 'trialing'])(
'shows trial-end copy only for trialing subscriptions (%s)',
async status => {
StripeAPI.customer.mockResolvedValue({
data: {
...data,
subscriptions: [
{ ...data.subscriptions[0], status, trial_end: 1788998400 },
],
},
});
wrapper = mount(StripeCustomer, { props: { conversationId: 1 } });
await flushPromises();
await wrapper.get('button').trigger('click');
await flushPromises();
expect(
document.body.textContent.includes('STRIPE_INTEGRATION.TRIAL_ENDS')
).toBe(status === 'trialing');
}
);
afterEach(() => wrapper?.unmount());
it('renders customer billing details without a refresh button', async () => {
StripeAPI.customer.mockResolvedValue({ data });
wrapper = mount(StripeCustomer, { props: { conversationId: 1 } });
await flushPromises();
expect(wrapper.text()).toContain('Alfred');
expect(wrapper.text()).toContain('INV-001');
expect(wrapper.text()).toContain('$10.00');
expect(wrapper.text()).toContain('paid');
expect(wrapper.get('button').attributes('aria-label')).toBe(
'STRIPE_INTEGRATION.VIEW_SUBSCRIPTION'
);
});
it('shows the no-email state', async () => {
StripeAPI.customer.mockResolvedValue({
data: { customers: [], missing_email: true },
});
wrapper = mount(StripeCustomer, { props: { conversationId: 1 } });
await flushPromises();
expect(wrapper.text()).toContain('STRIPE_INTEGRATION.NO_EMAIL');
});
it('shows scheduled cancellation instead of a renewal', async () => {
StripeAPI.customer.mockResolvedValue({
data: {
...data,
subscriptions: [
{ ...data.subscriptions[0], cancel_at_period_end: true },
],
},
});
wrapper = mount(StripeCustomer, { props: { conversationId: 1 } });
await flushPromises();
expect(wrapper.text()).toContain(
'STRIPE_INTEGRATION.CANCELS_AT_PERIOD_END'
);
expect(wrapper.text()).not.toContain('STRIPE_INTEGRATION.RENEWS');
});
it('does not display metered pricing as a fixed charge', async () => {
const subscription = data.subscriptions[0];
const item = subscription.items[0];
StripeAPI.customer.mockResolvedValue({
data: {
...data,
subscriptions: [
{
...subscription,
items: [
{
...item,
recurring: { ...item.recurring, usage_type: 'metered' },
},
],
},
],
},
});
wrapper = mount(StripeCustomer, { props: { conversationId: 1 } });
await flushPromises();
expect(wrapper.text()).toContain('STRIPE_INTEGRATION.VARIABLE_PRICE');
expect(wrapper.text()).not.toContain('STRIPE_INTEGRATION.PRICE_INTERVAL');
});
it('opens subscription details and removes them when the conversation changes', async () => {
StripeAPI.customer.mockResolvedValueOnce({ data });
wrapper = mount(StripeCustomer, { props: { conversationId: 1 } });
await flushPromises();
expect(wrapper.text()).toContain('Support Pro');
await wrapper.get('button').trigger('click');
expect(document.querySelector('[role="dialog"]').textContent).toContain(
'sub_test'
);
expect(document.querySelector('[role="dialog"]').textContent).toContain(
'STRIPE_INTEGRATION.PERIOD_START'
);
StripeAPI.customer.mockResolvedValueOnce({ data: { customers: [] } });
await wrapper.setProps({ conversationId: 2 });
await flushPromises();
expect(document.querySelector('[role="dialog"]')).toBeNull();
});
it('lets the agent select between customers sharing the same email', async () => {
StripeAPI.customer.mockResolvedValueOnce({
data: { customers: [customer, { ...customer, id: 'cus_second' }] },
});
wrapper = mount(StripeCustomer, { props: { conversationId: 1 } });
await flushPromises();
StripeAPI.customer.mockResolvedValueOnce({ data });
await wrapper.get('select').setValue('cus_alfred');
await flushPromises();
expect(StripeAPI.customer).toHaveBeenLastCalledWith(
1,
'cus_alfred',
expect.any(AbortSignal)
);
expect(wrapper.text()).toContain('INV-001');
});
it('clears the previous customer when switching to an unmatched conversation', async () => {
StripeAPI.customer.mockResolvedValueOnce({ data });
wrapper = mount(StripeCustomer, { props: { conversationId: 1 } });
await flushPromises();
StripeAPI.customer.mockResolvedValueOnce({ data: { customers: [] } });
await wrapper.setProps({ conversationId: 2 });
await flushPromises();
expect(StripeAPI.customer).toHaveBeenLastCalledWith(
2,
undefined,
expect.any(AbortSignal)
);
expect(wrapper.text()).not.toContain('Alfred');
expect(wrapper.text()).toContain('STRIPE_INTEGRATION.EMPTY');
});
it('shows an error without exposing the previous customer', async () => {
StripeAPI.customer.mockResolvedValueOnce({ data });
wrapper = mount(StripeCustomer, { props: { conversationId: 1 } });
await flushPromises();
StripeAPI.customer.mockRejectedValueOnce(
new Error('private provider error')
);
await wrapper.setProps({ conversationId: 2 });
await flushPromises();
expect(wrapper.get('[role="alert"]').text()).toContain(
'STRIPE_INTEGRATION.ERROR'
);
expect(wrapper.text()).not.toContain('Alfred');
expect(wrapper.text()).not.toContain('private provider error');
});
it('reloads and clears the selected customer after the contact email changes', async () => {
StripeAPI.customer.mockResolvedValueOnce({
data: { customers: [customer, { ...customer, id: 'cus_second' }] },
});
wrapper = mount(StripeCustomer, {
props: { conversationId: 1, contactEmail: customer.email },
});
await flushPromises();
StripeAPI.customer.mockResolvedValueOnce({ data });
await wrapper.get('select').setValue('cus_alfred');
await flushPromises();
StripeAPI.customer.mockResolvedValueOnce({
data: { customers: [], missing_email: true },
});
await wrapper.setProps({ contactEmail: '' });
await flushPromises();
expect(StripeAPI.customer).toHaveBeenLastCalledWith(
1,
undefined,
expect.any(AbortSignal)
);
expect(wrapper.text()).not.toContain('Alfred');
expect(wrapper.text()).toContain('STRIPE_INTEGRATION.NO_EMAIL');
StripeAPI.customer.mockResolvedValueOnce({ data });
await wrapper.setProps({ contactEmail: customer.email });
await flushPromises();
expect(wrapper.text()).toContain('INV-001');
});
it.each([
['jpy', 1000, '1,000'],
['isk', 1000, '10'],
])('formats %s using Stripe minor units', async (currency, total, amount) => {
StripeAPI.customer.mockResolvedValue({
data: { ...data, invoices: [{ ...data.invoices[0], currency, total }] },
});
wrapper = mount(StripeCustomer, { props: { conversationId: 1 } });
await flushPromises();
expect(wrapper.get('.tabular-nums').text()).toContain(amount);
});
});

View File

@@ -1,80 +0,0 @@
import { computed, onMounted } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import TasksAPI from 'dashboard/api/captain/tasks';
/**
* Cleans and normalizes a list of labels.
* @param {string} labels - A comma-separated string of labels.
* @returns {string[]} An array of cleaned and unique labels.
*/
const cleanLabels = labels => {
return labels
.toLowerCase()
.split(',')
.filter(label => label.trim())
.map(label => label.trim())
.filter((label, index, self) => self.indexOf(label) === index);
};
export function useLabelSuggestions() {
const store = useStore();
const { isCloudFeatureEnabled } = useAccount();
const appIntegrations = useMapGetter('integrations/getAppIntegrations');
const currentChat = useMapGetter('getSelectedChat');
const conversationId = computed(() => currentChat.value?.id);
const captainTasksEnabled = computed(() => {
return isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN_TASKS);
});
const aiIntegration = computed(
() =>
appIntegrations.value.find(
integration => integration.id === 'openai' && !!integration.hooks.length
)?.hooks[0]
);
const isLabelSuggestionFeatureEnabled = computed(() => {
if (aiIntegration.value) {
const { settings = {} } = aiIntegration.value || {};
return !!settings.label_suggestion;
}
return false;
});
const fetchIntegrationsIfRequired = async () => {
if (!appIntegrations.value.length) {
await store.dispatch('integrations/get');
}
};
/**
* Gets label suggestions for the current conversation.
* @returns {Promise<string[]>} An array of suggested labels.
*/
const getLabelSuggestions = async () => {
if (!conversationId.value) return [];
try {
const result = await TasksAPI.labelSuggestion(conversationId.value);
const {
data: { message: labels },
} = result;
return cleanLabels(labels);
} catch {
return [];
}
};
onMounted(() => {
fetchIntegrationsIfRequired();
});
return {
captainTasksEnabled,
isLabelSuggestionFeatureEnabled,
getLabelSuggestions,
};
}

View File

@@ -12,6 +12,7 @@ export const DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER = Object.freeze([
{ name: 'previous_conversation' },
{ name: 'conversation_participants' },
{ name: 'linear_issues' },
{ name: 'stripe_customer' },
{ name: 'shopify_orders' },
]);

View File

@@ -4,11 +4,9 @@ export const LOCAL_STORAGE_KEYS = {
WIDGET_BUILDER: 'widgetBubble_',
DRAFT_MESSAGES: 'draftMessages',
COLOR_SCHEME: 'color_scheme',
DISMISSED_LABEL_SUGGESTIONS: 'labelSuggestionsDismissed',
MESSAGE_REPLY_TO: 'messageReplyTo',
RECENT_SEARCHES: 'recentSearches',
SIDEBAR_MINIMIZED_SECTIONS: 'sidebarMinimizedSections',
CAPTAIN_OVERVIEW_V2: 'captain_overview_v2',
CAPTAIN_ASSISTANT_TOOLS_BANNER_DISMISSED:
'captain_assistant_tools_banner_dismissed',
};

View File

@@ -39,6 +39,7 @@ export const FEATURE_FLAGS = {
IP_LOOKUP: 'ip_lookup',
LINEAR: 'linear_integration',
SHOPIFY: 'shopify_integration',
STRIPE: 'stripe_integration',
CAPTAIN: 'captain_integration',
CUSTOM_ROLES: 'custom_roles',
CHATWOOT_V4: 'chatwoot_v4',

View File

@@ -110,10 +110,6 @@ export const CAPTAIN_EVENTS = Object.freeze({
// Follow-up events
FOLLOW_UP_SENT: 'Captain: Follow-up sent',
// Label suggestions
LABEL_SUGGESTION_APPLIED: 'Captain: Label suggestion applied',
LABEL_SUGGESTION_DISMISSED: 'Captain: Label suggestion dismissed',
// Conversation sidebar suggestions
LABEL_SUGGESTIONS_REQUESTED: 'Captain: Label suggestions requested',
PRIORITY_SUGGESTION_REQUESTED: 'Captain: Priority suggestion requested',

View File

@@ -1,31 +1,5 @@
const FEATURE_HELP_URLS = {
agent_bots: 'https://chwt.app/hc/agent-bots',
agents: 'https://chwt.app/hc/agents',
audit_logs: 'https://chwt.app/hc/audit-logs',
campaigns: 'https://chwt.app/hc/campaigns',
canned_responses: 'https://chwt.app/hc/canned',
channel_email: 'https://chwt.app/hc/email',
channel_facebook: 'https://chwt.app/hc/fb',
custom_attributes: 'https://chwt.app/hc/custom-attributes',
dashboard_apps: 'https://chwt.app/hc/dashboard-apps',
help_center: 'https://chwt.app/hc/help-center',
inboxes: 'https://chwt.app/hc/inboxes',
integrations: 'https://chwt.app/hc/integrations',
labels: 'https://chwt.app/hc/labels',
macros: 'https://chwt.app/hc/macros',
reports: 'https://chwt.app/hc/reports',
sla: 'https://chwt.app/hc/sla',
team_management: 'https://chwt.app/hc/teams',
webhook: 'https://chwt.app/hc/webhooks',
whatsapp_templates:
'https://www.chatwoot.com/hc/user-guide/articles/1754940076-whatsapp-templates',
billing: 'https://chwt.app/pricing',
saml: 'https://chwt.app/hc/saml',
captain: 'https://chwt.app/captain-docs',
captain_billing: 'https://chwt.app/hc/captain_billing',
shopify: 'https://chwt.app/hc/shopify',
};
import helpUrls from '../../../../config/feature_help_urls.yml';
export function getHelpUrlForFeature(featureName) {
return FEATURE_HELP_URLS[featureName];
return helpUrls[featureName];
}

View File

@@ -0,0 +1,30 @@
import { formatStripeAmount } from '../stripeHelper';
describe('formatStripeAmount', () => {
it('preserves fractional minor units for subscription prices', () => {
expect(formatStripeAmount('0.05', 'USD', { preservePrecision: true })).toBe(
'$0.0005'
);
expect(
formatStripeAmount('0.000000000001', 'USD', { preservePrecision: true })
).toBe('$0.00000000000001');
});
it('keeps standard invoice precision and trims insignificant price zeros', () => {
expect(formatStripeAmount(1000, 'USD')).toBe('$10.00');
expect(
formatStripeAmount('1000.00', 'USD', { preservePrecision: true })
).toBe('$10.00');
});
it('uses two-decimal API units for UGX', () => {
expect(formatStripeAmount(5000, 'UGX')).toBe(
new Intl.NumberFormat(undefined, {
style: 'currency',
currency: 'UGX',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(50)
);
});
});

View File

@@ -0,0 +1,49 @@
// Stripe API units are independent of the locale's display precision.
const ZERO_DECIMAL_CURRENCIES = [
'BIF',
'CLP',
'DJF',
'GNF',
'JPY',
'KMF',
'KRW',
'MGA',
'PYG',
'RWF',
'VND',
'VUV',
'XAF',
'XOF',
'XPF',
];
const THREE_DECIMAL_CURRENCIES = ['BHD', 'JOD', 'KWD', 'OMR', 'TND'];
export const STRIPE_STATUS_COLORS = {
active: 'teal',
paid: 'teal',
trialing: 'blue',
open: 'amber',
past_due: 'ruby',
unpaid: 'ruby',
uncollectible: 'ruby',
};
export const formatStripeAmount = (
amount,
currency,
{ preservePrecision = false } = {}
) => {
const code = currency.toUpperCase();
let digits = 2;
if (ZERO_DECIMAL_CURRENCIES.includes(code)) digits = 0;
if (THREE_DECIMAL_CURRENCIES.includes(code)) digits = 3;
const fractionalDigits = preservePrecision
? (String(amount).split('.')[1] || '').replace(/0+$/, '').length
: 0;
return new Intl.NumberFormat(undefined, {
style: 'currency',
currency: code,
minimumFractionDigits: digits,
maximumFractionDigits: digits + fractionalDigits,
}).format(Number(amount) / 10 ** digits);
};

View File

@@ -75,7 +75,7 @@
"ACTIVITY": {
"TODAY": "Today",
"YESTERDAY": "Yesterday",
"UNKNOWN_AUTHOR": "Someone",
"SYSTEM_AUTHOR": "System",
"EMPTY_CONVERSATIONS": "No conversations yet. They'll show up here when this company's people get in touch.",
"EMPTY_NOTES": "No notes yet. Notes added to this company's people show up here.",
"LOAD_MORE": "Load more",

View File

@@ -1,4 +1,54 @@
{
"STRIPE_INTEGRATION": {
"UNAVAILABLE": "Stripe is unavailable for this account. Contact your administrator to check the integration configuration.",
"TITLE": "Stripe",
"DESCRIPTION": "View customer details, subscriptions, and invoices alongside your conversations.",
"CONNECT": "Connect",
"DISCONNECT": "Disconnect from Chatwoot",
"DISCONNECT_NOTE": "Disconnecting removes the saved credentials from Chatwoot. Uninstall the app in Stripe to revoke its access there.",
"CONNECTED": "Connected",
"RECONNECT": "Reconnect Stripe",
"REAUTHORIZATION_REQUIRED": "Reconnect required",
"ACCOUNT": "Connected account",
"ACCOUNT_ID": "Account ID",
"ENVIRONMENT": "Environment",
"SANDBOX": "Sandbox",
"LIVE": "Live",
"CONNECTED_ON": "Connected on",
"OPEN_STRIPE": "Open Stripe dashboard",
"ERROR": "Unable to load Stripe. Please try again or reconnect the integration.",
"EMPTY": "No Stripe customer matches this contact's email.",
"NO_EMAIL": "Add an email to this contact to look up Stripe customers.",
"SELECT": "Select a matching customer",
"CUSTOMER_OPTION": "{name} — {id}",
"MORE": "Showing the first 100 matching customers. Refine the contact email if the customer is not listed.",
"SUBSCRIPTIONS": "Latest five subscriptions",
"SUBSCRIPTION": "Subscription",
"VIEW_SUBSCRIPTION": "View {name} subscription details",
"COPY_ID": "Copy ID",
"COPIED": "Copied",
"QUANTITY": "Quantity: {count}",
"PRICE_INTERVAL": "{amount} / {interval}",
"INTERVAL": {
"day": "day | {count} days",
"week": "week | {count} weeks",
"month": "month | {count} months",
"year": "year | {count} years"
},
"VARIABLE_PRICE": "Usage-based or tiered pricing",
"RENEWS": "Renews {date}",
"PERIOD_START": "Period starts {date}",
"PERIOD_END": "Period ends {date}",
"TRIAL_ENDS": "Trial ends {date}",
"CANCELS": "Cancels {date}",
"CANCELS_AT_PERIOD_END": "Cancels at the end of the billing period",
"CANCELED_ON": "Canceled {date}",
"MORE_ITEMS": "Additional subscription items are not shown.",
"PRICE_NOTE": "Prices are per unit, before discounts and taxes. They are not an estimate of the next invoice.",
"INVOICES": "Recent invoices",
"NO_SUBSCRIPTIONS": "No subscriptions found.",
"NO_INVOICES": "No invoices found."
},
"INTEGRATION_SETTINGS": {
"SHOPIFY": {
"HEADER": "Shopify",
@@ -1281,18 +1331,38 @@
"CUSTOM_TOOLS": {
"HEADER": "Tools",
"ADD_NEW": "Create a new tool",
"CATALOG": {
"BUTTON": "Tools Catalog",
"HEADER": "Tools Catalog",
"DESCRIPTION": "Browse toolsets from the Captain tools catalog and install them on this assistant.",
"ALL": "All",
"EMPTY": "No toolsets in this category yet.",
"LOAD_ERROR": "Could not load the tools catalog. Check the catalog URL in the Captain settings.",
"TOOL_COUNT": "{n} tool | {n} tools",
"VERSION": "v{version}",
"UPDATED": "Updated {time}",
"REPOSITORY": "Repository",
"MANIFEST": "Manifest",
"CONFIGURATION": "Configuration",
"REQUIRED": "Required",
"DETAILS_ERROR": "Could not load the toolset details.",
"INSTALLED": "Installed",
"UPDATE_AVAILABLE": "Update available"
},
"INSTALL_LINK": {
"TITLE": "Choose an assistant",
"DESCRIPTION": "Pick the assistant to install this toolset on.",
"PLACEHOLDER": "Select an assistant",
"LOAD_ERROR": "Could not load your assistants. Reload the page to try again."
},
"INSTALL_MANIFEST": {
"TITLE": "Install a toolset",
"DESCRIPTION": "Install a toolset from the Captain tools catalog.",
"SOURCE_LABEL": "Toolset",
"SOURCE_PLACEHOLDER": "chatwoot/tools/folder",
"SOURCE_HELP": "Installs the latest version from the catalog.",
"VERSION_LABEL": "v{version} · {revision}",
"UPDATE_NOTE": "A different version is installed. Updating replaces its tools and keeps whether each one is enabled. Enter the secrets again.",
"TOOLS_LABEL": "Tools",
"CONFIGURATION_LABEL": "Configuration",
"CONTINUE": "Continue",
"BACK": "Back",
"INSTALL": "Install",
"UPDATE": "Update",
"REINSTALL": "Reinstall",

View File

@@ -43,20 +43,6 @@
"DELETE": "Delete",
"CANCEL": "Cancel"
},
"SUGGESTIONS": {
"TOOLTIP": {
"SINGLE_SUGGESTION": "Add label to conversation",
"MULTIPLE_SUGGESTION": "Select this label",
"DESELECT": "Deselect label",
"DISMISS": "Dismiss suggestion"
},
"POWERED_BY": "Chatwoot AI",
"DISMISS": "Dismiss",
"ADD_SELECTED_LABELS": "Add selected labels",
"ADD_SELECTED_LABEL": "Add selected label",
"ADD_ALL_LABELS": "Add all labels",
"SUGGESTED_LABELS": "Suggested labels"
},
"ADD": {
"TITLE": "Add label",
"DESC": "Labels let you group the conversations together.",

View File

@@ -569,12 +569,6 @@
"HELP_CENTER_SEARCH": {
"TITLE": "Help Center Search Indexing",
"DESCRIPTION": "Use AI for context aware search inside your help center articles."
},
"LABEL_SUGGESTION": {
"TITLE": "Label Suggestion",
"DESCRIPTION": "Automatically suggest relevant labels and tags for conversations based on content analysis and context.",
"MODEL_TITLE": "Label Suggestion Model",
"MODEL_DESCRIPTION": "Select the AI model to use for analyzing conversations and suggesting appropriate labels"
}
},
"API": {

View File

@@ -1,268 +0,0 @@
<script setup>
import { computed, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { usePolicy } from 'dashboard/composables/usePolicy';
import CaptainAssistant from 'dashboard/api/captain/assistant';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import OverviewV2 from 'dashboard/components-next/captain/pageComponents/overview/v2/OverviewV2.vue';
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
import RangeSelector from 'dashboard/components-next/captain/pageComponents/overview/RangeSelector.vue';
import WelcomeCard from 'dashboard/components-next/captain/pageComponents/overview/WelcomeCard.vue';
import MetricCard from 'dashboard/components-next/captain/pageComponents/overview/MetricCard.vue';
import AssistantDrilldownDrawer from 'dashboard/components-next/captain/pageComponents/overview/AssistantDrilldownDrawer.vue';
import KnowledgeCard from 'dashboard/components-next/captain/pageComponents/overview/KnowledgeCard.vue';
import QuickLinks from 'dashboard/components-next/captain/pageComponents/overview/QuickLinks.vue';
import InboxBanner from 'dashboard/components-next/captain/pageComponents/overview/InboxBanner.vue';
import CoverageBanner from 'dashboard/components-next/captain/pageComponents/overview/CoverageBanner.vue';
const { t } = useI18n();
const route = useRoute();
const isOverviewV2Enabled =
LocalStorage.get(LOCAL_STORAGE_KEYS.CAPTAIN_OVERVIEW_V2) === true;
// Drilldown is admin-only; the backend policy enforces the same restriction.
const { checkPermissions } = usePolicy();
const canDrilldown = computed(() => checkPermissions(['administrator']));
const selectedRange = ref('7');
const assistantId = computed(() => route.params.assistantId);
const metricStats = ref(null);
const faqStats = ref(null);
const isFetchingMetrics = ref(false);
// Increments on every fetch so a response (or retry) from a superseded
// range/assistant can't clobber the latest request's state.
let metricsFetchToken = 0;
let faqStatsFetchToken = 0;
let metricsAbortController = null;
let faqStatsAbortController = null;
const fetchMetrics = async () => {
metricsFetchToken += 1;
const token = metricsFetchToken;
metricsAbortController?.abort();
metricsAbortController = new AbortController();
const { signal } = metricsAbortController;
metricStats.value = null;
isFetchingMetrics.value = true;
const requestMetrics = () =>
CaptainAssistant.getMetrics({
assistantId: assistantId.value,
range: selectedRange.value,
signal,
});
let data = null;
try {
({ data } = await requestMetrics());
} catch {
// One silent retry before giving up, unless the request was aborted.
try {
if (token === metricsFetchToken && !signal.aborted)
({ data } = await requestMetrics());
} catch {
data = null;
}
}
if (token !== metricsFetchToken || signal.aborted) return;
metricStats.value = data;
isFetchingMetrics.value = false;
};
const fetchFaqStats = async () => {
faqStatsFetchToken += 1;
const token = faqStatsFetchToken;
faqStatsAbortController?.abort();
faqStatsAbortController = new AbortController();
const { signal } = faqStatsAbortController;
faqStats.value = null;
try {
const { data } = await CaptainAssistant.getFaqStats({
assistantId: assistantId.value,
signal,
});
if (token === faqStatsFetchToken && !signal.aborted) faqStats.value = data;
} catch {
if (token === faqStatsFetchToken && !signal.aborted) faqStats.value = null;
}
};
const summaryStats = computed(() => {
if (!metricStats.value || !faqStats.value) return null;
return { ...metricStats.value, knowledge: faqStats.value };
});
onUnmounted(() => {
metricsAbortController?.abort();
faqStatsAbortController?.abort();
});
if (!isOverviewV2Enabled) {
watch([selectedRange, assistantId], fetchMetrics, { immediate: true });
watch(assistantId, fetchFaqStats, { immediate: true });
}
// `direction` says whether a rising trend is good ('up'), bad ('down'), or
// neutral, so we can colour the delta independently of its sign.
const resolveTrendGood = (trendValue, direction) => {
if (direction === 'neutral' || trendValue === 0) return null;
return direction === 'up' ? trendValue > 0 : trendValue < 0;
};
// Trend units mirror the backend pack mode: a relative percent change ('%') for
// :percent metrics, a percentage-point delta (' pts') for rate metrics packed as
// :point, and a plain number for :absolute counts like conversation depth.
const TREND_SUFFIX = { percent: '%', point: ' pts', absolute: '' };
// Hours-saved is reported in hours, but large values read better as days. Past
// 100h we switch the unit so the card stays legible.
const formatDuration = hours =>
hours >= 100 ? `${Math.round(hours / 24)}d` : `${hours}h`;
const metricFor = (statKey, formatValue, direction, trendKind = 'percent') => {
const data = metricStats.value?.[statKey];
if (!data) return { value: '—', trend: '', trendGood: null };
const sign = data.trend > 0 ? '+' : '';
return {
value: formatValue(data.current),
trend: `${sign}${data.trend}${TREND_SUFFIX[trendKind]}`,
trendGood: resolveTrendGood(data.trend, direction),
};
};
const metrics = computed(() => [
{
key: 'handled',
metric: 'conversations_handled',
label: t('CAPTAIN.OVERVIEW.METRICS.HANDLED.LABEL'),
hint: t('CAPTAIN.OVERVIEW.METRICS.HANDLED.HINT'),
...metricFor('conversations_handled', v => v.toLocaleString(), 'up'),
},
{
key: 'autoResolution',
metric: 'auto_resolution_rate',
label: t('CAPTAIN.OVERVIEW.METRICS.AUTO_RESOLUTION.LABEL'),
hint: t('CAPTAIN.OVERVIEW.METRICS.AUTO_RESOLUTION.HINT'),
...metricFor('auto_resolution_rate', v => `${v}%`, 'up', 'point'),
},
{
key: 'handoff',
metric: 'handoff_rate',
label: t('CAPTAIN.OVERVIEW.METRICS.HANDOFF.LABEL'),
hint: t('CAPTAIN.OVERVIEW.METRICS.HANDOFF.HINT'),
...metricFor('handoff_rate', v => `${v}%`, 'down', 'point'),
},
{
key: 'reopen',
metric: 'reopen_rate',
label: t('CAPTAIN.OVERVIEW.METRICS.REOPEN.LABEL'),
hint: t('CAPTAIN.OVERVIEW.METRICS.REOPEN.HINT'),
...metricFor('reopen_rate', v => `${v}%`, 'down', 'point'),
},
{
key: 'hoursSaved',
label: t('CAPTAIN.OVERVIEW.METRICS.HOURS_SAVED.LABEL'),
hint: t('CAPTAIN.OVERVIEW.METRICS.HOURS_SAVED.HINT'),
...metricFor('hours_saved', formatDuration, 'up'),
},
{
key: 'depth',
label: t('CAPTAIN.OVERVIEW.METRICS.DEPTH.LABEL'),
hint: t('CAPTAIN.OVERVIEW.METRICS.DEPTH.HINT'),
...metricFor(
'conversation_depth',
v => v.toFixed(1),
'neutral',
'absolute'
),
},
]);
const drilldown = ref({ metric: '', label: '', value: '' });
const isDrilldownOpen = ref(false);
const openDrilldown = metric => {
drilldown.value = {
metric: metric.metric,
label: metric.label,
value: metric.value,
};
isDrilldownOpen.value = true;
};
const closeDrilldown = () => {
isDrilldownOpen.value = false;
};
</script>
<template>
<OverviewV2 v-if="isOverviewV2Enabled" />
<PageLayout
v-else
:header-title="$t('CAPTAIN.OVERVIEW.HEADER')"
:is-empty="false"
:show-pagination-footer="false"
:show-know-more="false"
:feature-flag="FEATURE_FLAGS.CAPTAIN"
>
<template #headerActions>
<RangeSelector v-model="selectedRange" />
</template>
<template #paywall>
<CaptainPaywall />
</template>
<template #body>
<div class="flex flex-col gap-6 pb-8">
<InboxBanner />
<CoverageBanner :knowledge="faqStats ?? undefined" />
<WelcomeCard :range="selectedRange" :stats="summaryStats" />
<div
class="grid grid-cols-1 gap-px overflow-hidden border rounded-xl sm:grid-cols-2 lg:grid-cols-3 bg-n-weak border-n-weak"
>
<MetricCard
v-for="metric in metrics"
:key="metric.key"
:label="metric.label"
:value="metric.value"
:trend="metric.trend"
:hint="metric.hint"
:trend-good="metric.trendGood"
:loading="isFetchingMetrics"
:clickable="
canDrilldown && Boolean(metric.metric) && !isFetchingMetrics
"
@click="openDrilldown(metric)"
/>
</div>
<KnowledgeCard :knowledge="faqStats ?? undefined" />
<QuickLinks />
</div>
<AssistantDrilldownDrawer
v-if="canDrilldown"
:open="isDrilldownOpen"
:assistant-id="assistantId"
:metric="drilldown.metric"
:metric-name="drilldown.label"
:metric-value="drilldown.value"
:range="selectedRange"
@close="closeDrilldown"
/>
</template>
</PageLayout>
</template>

View File

@@ -10,7 +10,7 @@ import CaptainPageRouteView from './pages/CaptainPageRouteView.vue';
import AssistantsIndexPage from './pages/AssistantsIndexPage.vue';
import AssistantEmptyStateIndex from './assistants/Index.vue';
import AssistantOverviewIndex from './assistants/overview/Index.vue';
import AssistantOverviewIndex from 'dashboard/components-next/captain/pageComponents/overview/v2/OverviewV2.vue';
import AssistantSettingsIndex from './assistants/settings/Index.vue';
import AssistantSystemSettingsIndex from './assistants/settings/System.vue';
import AssistantAudienceSettingsIndex from './assistants/settings/Audience.vue';
@@ -24,6 +24,8 @@ import DocumentsIndex from './documents/Index.vue';
import ResponsesIndex from './responses/Index.vue';
import FaqSuggestionsIndex from './responses/FaqSuggestions.vue';
import CustomToolsIndex from './tools/Index.vue';
import CustomToolsExplore from './tools/Explore.vue';
import CustomToolsInstall from './tools/Install.vue';
const meta = {
permissions: ['administrator', 'agent'],
@@ -73,6 +75,18 @@ const assistantRoutes = [
name: 'captain_tools_index',
meta: metaCustomTools,
},
{
path: frontendURL('accounts/:accountId/captain/:assistantId/tools/explore'),
component: CustomToolsExplore,
name: 'captain_tools_explore',
meta: metaCustomTools,
},
{
path: frontendURL('accounts/:accountId/captain/toolsets/install'),
component: CustomToolsInstall,
name: 'captain_toolset_install',
meta: { ...metaCustomTools, permissions: ['administrator'] },
},
{
path: frontendURL('accounts/:accountId/captain/:assistantId/scenarios'),
component: AssistantScenariosIndex,

View File

@@ -0,0 +1,207 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import semver from 'semver';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useMapGetter } from 'dashboard/composables/store';
import { useAbortableRequest } from 'dashboard/composables/useAbortableRequest';
import { usePolicy } from 'dashboard/composables/usePolicy';
import ToolsManifestAPI from 'dashboard/api/captain/toolsManifest';
import {
fetchToolsCatalog,
isChatwootAuthor,
} from 'dashboard/api/captain/toolsCatalog';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
import InstallManifestDialog from 'dashboard/components-next/captain/pageComponents/customTool/InstallManifestDialog.vue';
import ToolsetCatalogCard from 'dashboard/components-next/captain/pageComponents/customTool/ToolsetCatalogCard.vue';
import ToolsetDetailsPanel from 'dashboard/components-next/captain/pageComponents/customTool/ToolsetDetailsPanel.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const globalConfig = useMapGetter('globalConfig/get');
const { shouldShowPaywall } = usePolicy();
const { run: runCatalogRequest, isPending: isLoading } = useAbortableRequest();
const { run: runInstalledRequest } = useAbortableRequest();
const assistantId = computed(() => route.params.assistantId);
const installManifestDialogRef = ref(null);
const detailsPanelRef = ref(null);
const toolsets = ref([]);
const hasError = ref(false);
// null shows every category
const selectedCategory = ref(null);
// Installed version by owner/repository/folder; installs lowercase the owner and repository but keep the folder's case
const installedVersions = ref(new Map());
const toolsRoute = computed(() => ({
name: 'captain_tools_index',
params: {
accountId: route.params.accountId,
assistantId: assistantId.value,
},
}));
const categoryPills = computed(() => [
{ value: null, label: t('CAPTAIN.CUSTOM_TOOLS.CATALOG.ALL') },
...[...new Set(toolsets.value.map(toolset => toolset.category))].map(
category => ({ value: category, label: category })
),
]);
const visibleToolsets = computed(() =>
selectedCategory.value
? toolsets.value.filter(
toolset => toolset.category === selectedCategory.value
)
: toolsets.value
);
const loadCatalog = async () => {
hasError.value = false;
try {
const catalog = await runCatalogRequest(signal =>
fetchToolsCatalog(globalConfig.value.captainToolsCatalogURL, { signal })
);
// Chatwoot's own toolsets lead; the stable sort keeps the catalog order otherwise
if (catalog)
toolsets.value = catalog.toolsets.toSorted(
(a, b) => isChatwootAuthor(b.author) - isChatwootAuthor(a.author)
);
} catch {
hasError.value = true;
}
};
const loadInstalled = async () => {
const response = await runInstalledRequest(signal =>
ToolsManifestAPI.installed({ assistantId: assistantId.value }, { signal })
);
if (!response) return;
installedVersions.value = new Map(
response.data.map(({ repository, path, version }) => [
`${repository}/${path}`,
version,
])
);
};
const installStatus = toolset => {
const [owner, repository, ...folder] = toolset.source.split('/');
const key = `${owner}/${repository}`.toLowerCase() + `/${folder.join('/')}`;
if (!installedVersions.value.has(key)) return null;
// The catalog can lag behind GitHub, so a newer install than the catalog lists is not an update
return semver.gt(toolset.version, installedVersions.value.get(key))
? 'update'
: 'installed';
};
const openToolset = toolset =>
detailsPanelRef.value.open(toolset, installStatus(toolset));
const installToolset = toolset =>
installManifestDialogRef.value.open(toolset.source);
const goToTools = () => router.push(toolsRoute.value);
const showPaywall = computed(() =>
shouldShowPaywall(FEATURE_FLAGS.CAPTAIN_CUSTOM_TOOLS)
);
// On a full reload account features load after mount, so the catalog loads once access resolves
watch(
showPaywall,
isPaywalled => {
if (!isPaywalled) loadCatalog();
},
{ immediate: true }
);
watch(
[showPaywall, assistantId],
() => {
// Drop the previous assistant's badges so they never show while, or if, the new list fails to load
installedVersions.value = new Map();
if (!showPaywall.value) loadInstalled();
},
{ immediate: true }
);
</script>
<template>
<PageLayout
:header-title="t('CAPTAIN.CUSTOM_TOOLS.CATALOG.HEADER')"
:back-url="toolsRoute"
:feature-flag="FEATURE_FLAGS.CAPTAIN_CUSTOM_TOOLS"
:show-know-more="false"
:show-pagination-footer="false"
>
<template #paywall>
<CaptainPaywall feature-prefix="CAPTAIN.CUSTOM_TOOLS" />
</template>
<template #body>
<div class="flex flex-col gap-6">
<p class="mb-0 text-sm text-n-slate-11">
{{ t('CAPTAIN.CUSTOM_TOOLS.CATALOG.DESCRIPTION') }}
</p>
<div v-if="isLoading" class="flex justify-center py-10">
<Spinner />
</div>
<p v-else-if="hasError" class="mb-0 text-sm text-n-ruby-11">
{{ t('CAPTAIN.CUSTOM_TOOLS.CATALOG.LOAD_ERROR') }}
</p>
<template v-else>
<div class="flex flex-wrap gap-2">
<Button
v-for="pill in categoryPills"
:key="pill.label"
:label="pill.label"
size="sm"
:variant="selectedCategory === pill.value ? 'solid' : 'faded'"
:color="selectedCategory === pill.value ? 'blue' : 'slate'"
class="!rounded-full"
@click="selectedCategory = pill.value"
/>
</div>
<p
v-if="!visibleToolsets.length"
class="mb-0 text-sm text-n-slate-11"
>
{{ t('CAPTAIN.CUSTOM_TOOLS.CATALOG.EMPTY') }}
</p>
<div
v-else
class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"
>
<ToolsetCatalogCard
v-for="toolset in visibleToolsets"
:key="toolset.source"
:toolset="toolset"
:install-status="installStatus(toolset)"
@click="openToolset(toolset)"
/>
</div>
</template>
</div>
</template>
</PageLayout>
<ToolsetDetailsPanel ref="detailsPanelRef" @install="installToolset" />
<InstallManifestDialog
ref="installManifestDialogRef"
:assistant-id="assistantId"
@installed="goToTools"
/>
</template>

View File

@@ -47,6 +47,10 @@ vi.mock('dashboard/composables/store', async () => {
vi.mock('dashboard/composables', () => ({ useAlert: mocks.alerts }));
vi.mock('dashboard/composables/useAdmin', () => ({
useAdmin: () => ({ isAdmin: { value: true } }),
}));
vi.mock('dashboard/composables/usePolicy', async () => {
const { ref } = await import('vue');
// Reactive, so a spec can simulate account features loading after the page mounts
@@ -61,7 +65,7 @@ vi.mock('dashboard/composables/usePolicy', async () => {
vi.mock('vue-router', async importOriginal => ({
...(await importOriginal()),
useRoute: () => ({ params: { assistantId: '1' } }),
useRoute: () => ({ params: { assistantId: '1' }, query: {} }),
}));
vi.mock('vue-i18n', () => ({

View File

@@ -1,12 +1,13 @@
<script setup>
import { computed, watch, ref, nextTick } from 'vue';
import { useRoute } from 'vue-router';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useAbortableRequest } from 'dashboard/composables/useAbortableRequest';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { usePolicy } from 'dashboard/composables/usePolicy';
import { useAdmin } from 'dashboard/composables/useAdmin';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
@@ -15,15 +16,21 @@ import CreateCustomToolDialog from 'dashboard/components-next/captain/pageCompon
import CustomToolCard from 'dashboard/components-next/captain/pageComponents/customTool/CustomToolCard.vue';
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import ToolsetInstallFlow from 'dashboard/components-next/captain/pageComponents/customTool/ToolsetInstallFlow.vue';
import AssistantToolsBanner from 'dashboard/components-next/captain/pageComponents/customTool/AssistantToolsBanner.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Policy from 'dashboard/components/policy.vue';
const store = useStore();
const route = useRoute();
const router = useRouter();
const assistantId = computed(() => route.params.assistantId);
const { t } = useI18n();
const { shouldShowPaywall } = usePolicy();
const { isAdmin } = useAdmin();
const uiFlags = useMapGetter('captainCustomTools/getUIFlags');
const globalConfig = useMapGetter('globalConfig/get');
const { run, isPending: isFetchingTools } = useAbortableRequest();
const records = useMapGetter('captainCustomTools/getRecords');
const customTools = computed(() =>
@@ -81,6 +88,15 @@ const fetchCustomTools = (page = 1) =>
const onPageChange = page => fetchCustomTools(page);
const openCatalog = () =>
router.push({
name: 'captain_tools_explore',
params: {
accountId: route.params.accountId,
assistantId: assistantId.value,
},
});
const openCreateDialog = () => {
dialogType.value = 'create';
selectedTool.value = null;
@@ -183,6 +199,20 @@ const handleDialogClose = () => {
const handleToolCreated = () => fetchCustomTools();
// Install links land here with ?install=<source>; another assistant's tools load when the route changes
const onToolsetInstalled = installedAssistantId => {
if (installedAssistantId === Number(assistantId.value)) fetchCustomTools();
};
const finishToolsetInstall = installedAssistantId =>
router.replace({
name: 'captain_tools_index',
params: {
accountId: route.params.accountId,
assistantId: installedAssistantId,
},
});
const onDeleteSuccess = () => {
selectedTool.value = null;
if (customTools.value.length === 0 && customToolsMeta.value.page > 1) {
@@ -223,6 +253,25 @@ watch(
@update:current-page="onPageChange"
@click="openCreateDialog"
>
<template #headerActions>
<Policy
v-if="
globalConfig.captainToolsManifestEnabled &&
!shouldShowPaywall(FEATURE_FLAGS.CAPTAIN_CUSTOM_TOOLS)
"
:permissions="['administrator']"
>
<Button
:label="$t('CAPTAIN.CUSTOM_TOOLS.CATALOG.BUTTON')"
icon="i-lucide-blocks"
size="sm"
faded
slate
@click="openCatalog"
/>
</Policy>
</template>
<template #paywall>
<CaptainPaywall feature-prefix="CAPTAIN.CUSTOM_TOOLS" />
</template>
@@ -259,6 +308,14 @@ watch(
</template>
</PageLayout>
<ToolsetInstallFlow
v-if="isAdmin && !showPaywall && route.query.install"
:source="route.query.install"
:assistant-id="assistantId"
@installed="onToolsetInstalled"
@done="finishToolsetInstall"
/>
<CreateCustomToolDialog
v-if="dialogType"
ref="createDialogRef"

View File

@@ -0,0 +1,66 @@
<script setup>
import { onBeforeUnmount, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useUISettings } from 'dashboard/composables/useUISettings';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const route = useRoute();
const router = useRouter();
const store = useStore();
const { t } = useI18n();
const { uiSettings } = useUISettings();
const assistants = useMapGetter('captainAssistants/getRecords');
const hasError = ref(false);
// Leaving while assistants load must not pull the user back here once they arrive
let isUnmounted = false;
onBeforeUnmount(() => {
isUnmounted = true;
});
// Opens the tools page of the last active assistant, which runs the install flow from the query
onMounted(async () => {
const { accountId } = route.params;
const { source } = route.query;
try {
await store.dispatch('captainAssistants/get');
} catch {
hasError.value = true;
return;
}
if (isUnmounted) return;
if (!assistants.value.length) {
router.replace({
name: 'captain_assistants_create_index',
params: { accountId },
});
return;
}
const lastActiveId = Number(uiSettings.value?.last_active_assistant_id);
const assistantId = assistants.value.some(a => a.id === lastActiveId)
? lastActiveId
: assistants.value[0].id;
router.replace({
name: 'captain_tools_index',
params: { accountId, assistantId },
query: { install: source },
});
});
</script>
<template>
<div
class="flex items-center justify-center w-full bg-n-surface-1 text-n-slate-11"
>
<p v-if="hasError" class="mb-0 text-sm text-n-ruby-11">
{{ t('CAPTAIN.CUSTOM_TOOLS.INSTALL_LINK.LOAD_ERROR') }}
</p>
<Spinner v-else />
</div>
</template>

View File

@@ -24,6 +24,7 @@ import ShopifyOrdersList from 'dashboard/components/widgets/conversation/Shopify
import SidebarActionsHeader from 'dashboard/components-next/SidebarActionsHeader.vue';
import LinearIssuesList from 'dashboard/components/widgets/conversation/linear/IssuesList.vue';
import LinearSetupCTA from 'dashboard/components/widgets/conversation/linear/LinearSetupCTA.vue';
import StripeCustomer from 'dashboard/components/widgets/conversation/StripeCustomer.vue';
const props = defineProps({
conversationId: {
@@ -76,6 +77,10 @@ const isLinearConnected = computed(
);
const store = useStore();
const stripeIntegration = useFunctionGetter(
'integrations/getIntegration',
'stripe'
);
const currentChat = useMapGetter('getSelectedChat');
const conversationId = computed(() => props.conversationId);
const conversationMetadataGetter = useMapGetter(
@@ -135,8 +140,7 @@ onMounted(() => {
conversationSidebarItems.value = conversationSidebarItemsOrder.value;
getContactDetails();
store.dispatch('attributes/get', 0);
// Load integrations to ensure linear integration state is available
store.dispatch('integrations/get', 'linear');
store.dispatch('integrations/get');
});
</script>
@@ -299,6 +303,28 @@ onMounted(() => {
<ShopifyOrdersList :contact-id="contactId" />
</AccordionItem>
</div>
<div
v-else-if="
element.name === 'stripe_customer' &&
isCloudFeatureEnabled(FEATURE_FLAGS.STRIPE) &&
stripeIntegration.enabled
"
>
<AccordionItem
:title="$t('STRIPE_INTEGRATION.TITLE')"
:is-open="isContactSidebarItemOpen('is_stripe_customer_open')"
compact
@toggle="
value => toggleSidebarUIState('is_stripe_customer_open', value)
"
>
<StripeCustomer
v-if="isContactSidebarItemOpen('is_stripe_customer_open')"
:conversation-id="conversationId"
:contact-email="contact.email"
/>
</AccordionItem>
</div>
<div v-else-if="element.name === 'contact_notes'">
<AccordionItem
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.CONTACT_NOTES')"

View File

@@ -68,5 +68,11 @@ export default {
name: 'no_accounts',
component: NoAccounts,
},
{
// Install links from the tools catalog can't know the account; the router guard moves them into the current one
path: frontendURL('captain/toolsets/install'),
name: 'captain_toolset_install_link',
component: { render: () => null },
},
],
};

View File

@@ -46,9 +46,6 @@ const modelFeatures = computed(() => [
]);
const featureToggles = computed(() => [
{
key: 'label_suggestion',
},
{
key: 'help_center_search',
enterprise: true,

View File

@@ -46,9 +46,6 @@ const title = computed(() => {
if (props.featureKey.toUpperCase() === 'HELP_CENTER_SEARCH') {
return t('CAPTAIN_SETTINGS.FEATURES.HELP_CENTER_SEARCH.TITLE');
}
if (props.featureKey.toUpperCase() === 'LABEL_SUGGESTION') {
return t('CAPTAIN_SETTINGS.FEATURES.LABEL_SUGGESTION.TITLE');
}
return '';
});
@@ -59,9 +56,6 @@ const description = computed(() => {
if (props.featureKey.toUpperCase() === 'HELP_CENTER_SEARCH') {
return t('CAPTAIN_SETTINGS.FEATURES.HELP_CENTER_SEARCH.DESCRIPTION');
}
if (props.featureKey.toUpperCase() === 'LABEL_SUGGESTION') {
return t('CAPTAIN_SETTINGS.FEATURES.LABEL_SUGGESTION.DESCRIPTION');
}
return '';
});
@@ -72,9 +66,6 @@ const modelTitle = computed(() => {
if (props.featureKey.toUpperCase() === 'HELP_CENTER_SEARCH') {
return t('CAPTAIN_SETTINGS.FEATURES.HELP_CENTER_SEARCH.MODEL_TITLE');
}
if (props.featureKey.toUpperCase() === 'LABEL_SUGGESTION') {
return t('CAPTAIN_SETTINGS.FEATURES.LABEL_SUGGESTION.MODEL_TITLE');
}
return '';
});
@@ -85,9 +76,6 @@ const modelDescription = computed(() => {
if (props.featureKey.toUpperCase() === 'HELP_CENTER_SEARCH') {
return t('CAPTAIN_SETTINGS.FEATURES.HELP_CENTER_SEARCH.MODEL_DESCRIPTION');
}
if (props.featureKey.toUpperCase() === 'LABEL_SUGGESTION') {
return t('CAPTAIN_SETTINGS.FEATURES.LABEL_SUGGESTION.MODEL_DESCRIPTION');
}
return '';
});

View File

@@ -43,6 +43,9 @@ const integrationStatusColor = computed(() =>
props.enabled ? 'teal' : 'slate'
);
const isSvgLogo = computed(() => props.id === 'stripe');
const logoExtension = computed(() => (isSvgLogo.value ? 'svg' : 'png'));
const actionURL = computed(() =>
frontendURL(`accounts/${accountId.value}/settings/integrations/${props.id}`)
);
@@ -55,12 +58,20 @@ const actionURL = computed(() =>
<div class="flex items-start justify-between">
<div class="flex h-12 w-12 mb-2">
<img
:src="`/dashboard/images/integrations/${id}.png`"
class="max-w-full rounded-md border border-n-weak shadow-sm block dark:hidden bg-n-alpha-3 dark:bg-n-alpha-2"
:src="`/dashboard/images/integrations/${id}.${logoExtension}`"
:alt="name"
class="rounded-md border border-n-weak shadow-sm block dark:hidden"
:class="
isSvgLogo ? 'size-12 p-1.5 bg-white' : 'max-w-full bg-n-alpha-3'
"
/>
<img
:src="`/dashboard/images/integrations/${id}-dark.png`"
class="max-w-full rounded-md border border-n-weak shadow-sm hidden dark:block bg-n-alpha-3 dark:bg-n-alpha-2"
:src="`/dashboard/images/integrations/${id}-dark.${logoExtension}`"
:alt="name"
class="rounded-md border border-n-weak shadow-sm hidden dark:block"
:class="
isSvgLogo ? 'size-12 p-1.5 bg-n-solid-3' : 'max-w-full bg-n-alpha-2'
"
/>
</div>
<Label

View File

@@ -0,0 +1,235 @@
<script setup>
import { computed, ref, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { useStore, useFunctionGetter } from 'dashboard/composables/store';
import { useBranding } from 'shared/composables/useBranding';
import StripeAPI from 'dashboard/api/integrations/stripe';
import IntegrationsAPI from 'dashboard/api/integrations';
import Button from 'dashboard/components-next/button/Button.vue';
import Label from 'dashboard/components-next/label/Label.vue';
import SettingsLayout from '../SettingsLayout.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
const store = useStore();
const { replaceInstallationName } = useBranding();
const route = useRoute();
const integration = useFunctionGetter('integrations/getIntegration', 'stripe');
const loading = ref(true);
const loaded = ref(false);
const available = ref(false);
const busy = ref(false);
const error = ref(!!route.query.error);
const account = ref(null);
const STRIPE_LOGO = '/dashboard/images/integrations/stripe.svg';
const STRIPE_LOGO_DARK = '/dashboard/images/integrations/stripe-dark.svg';
const dashboardURL = computed(() => {
const { account_id: accountId, mode } = account.value;
return `https://dashboard.stripe.com/${accountId}/${mode === 'live' ? '' : 'test/'}dashboard`;
});
const loadIntegration = async () => {
const { data } = await IntegrationsAPI.get();
store.commit('integrations/SET_INTEGRATIONS', data.payload);
const stripe = data.payload.find(item => item.id === 'stripe');
available.value = !!stripe && !stripe.cleanup_only;
account.value =
stripe && (integration.value.enabled || stripe.cleanup_only)
? (await StripeAPI.get()).data
: null;
loaded.value = true;
};
const connect = async () => {
busy.value = true;
error.value = false;
try {
const { data } = await StripeAPI.connect();
window.location.assign(data.url);
} catch {
error.value = true;
} finally {
busy.value = false;
}
};
const disconnect = async () => {
busy.value = true;
error.value = false;
try {
await StripeAPI.disconnect();
account.value = null;
store.commit('integrations/DELETE_INTEGRATION', {
id: 'stripe',
enabled: false,
hooks: [],
});
await loadIntegration();
} catch {
error.value = true;
} finally {
busy.value = false;
}
};
onMounted(async () => {
try {
await loadIntegration();
} catch {
error.value = true;
} finally {
loading.value = false;
}
});
</script>
<template>
<SettingsLayout :is-loading="loading">
<template #header>
<BaseSettingsHeader
:title="$t('STRIPE_INTEGRATION.TITLE')"
:description="
integration.enabled ? $t('STRIPE_INTEGRATION.DESCRIPTION') : ''
"
:back-button-label="$t('INTEGRATION_SETTINGS.HEADER')"
/>
</template>
<template #body>
<div class="flex flex-col gap-6 w-full text-n-slate-12">
<p v-if="error" role="alert" class="text-n-ruby-11">
{{ $t('STRIPE_INTEGRATION.ERROR') }}
</p>
<p v-if="loaded && !available" role="status" class="text-n-slate-11">
{{ $t('STRIPE_INTEGRATION.UNAVAILABLE') }}
</p>
<template v-if="loaded && account">
<Button
v-if="available && account.reauthorization_required"
:label="$t('STRIPE_INTEGRATION.RECONNECT')"
:disabled="busy"
@click="connect"
/>
<div class="rounded-xl border border-n-weak bg-n-solid-1">
<div
class="flex flex-wrap items-center justify-between gap-4 p-6 border-b border-n-weak"
>
<div class="flex items-center gap-3">
<img :src="STRIPE_LOGO" alt="Stripe" class="w-16 dark:hidden" />
<img
:src="STRIPE_LOGO_DARK"
alt="Stripe"
class="w-16 hidden dark:block"
/>
<span class="text-sm font-medium">{{
$t('STRIPE_INTEGRATION.ACCOUNT')
}}</span>
</div>
<Label
v-if="account.reauthorization_required"
:label="$t('STRIPE_INTEGRATION.REAUTHORIZATION_REQUIRED')"
color="amber"
compact
/>
<Label
v-else
:label="$t('STRIPE_INTEGRATION.CONNECTED')"
color="teal"
compact
/>
</div>
<dl
class="grid grid-cols-1 sm:grid-cols-[10rem_1fr] gap-x-8 gap-y-3 p-6 text-sm m-0"
>
<dt class="text-n-slate-11">
{{ $t('STRIPE_INTEGRATION.ACCOUNT_ID') }}
</dt>
<dd class="m-0 break-all font-mono text-xs self-center">
{{ account.account_id }}
</dd>
<dt class="text-n-slate-11">
{{ $t('STRIPE_INTEGRATION.ENVIRONMENT') }}
</dt>
<dd class="m-0">
{{
account.mode === 'live'
? $t('STRIPE_INTEGRATION.LIVE')
: $t('STRIPE_INTEGRATION.SANDBOX')
}}
</dd>
<dt class="text-n-slate-11">
{{ $t('STRIPE_INTEGRATION.CONNECTED_ON') }}
</dt>
<dd class="m-0">
{{ new Date(account.connected_at).toLocaleDateString() }}
</dd>
</dl>
<div class="px-6 pb-6">
<a
:href="dashboardURL"
target="_blank"
rel="noopener noreferrer"
class="text-sm text-n-brand hover:underline"
>
{{ $t('STRIPE_INTEGRATION.OPEN_STRIPE') }}
</a>
</div>
</div>
<div
class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 rounded-xl border border-n-weak p-6"
>
<p class="m-0 text-sm text-n-slate-11 max-w-xl">
{{
replaceInstallationName(
$t('STRIPE_INTEGRATION.DISCONNECT_NOTE')
)
}}
</p>
<Button
:label="
replaceInstallationName($t('STRIPE_INTEGRATION.DISCONNECT'))
"
:disabled="busy"
variant="outline"
color="ruby"
@click="disconnect"
/>
</div>
</template>
<div
v-else-if="loaded && available"
class="flex flex-col items-start justify-between lg:flex-row lg:items-center p-6 outline outline-n-container outline-1 bg-n-card rounded-xl gap-6"
>
<div
class="flex items-start lg:items-center justify-start flex-1 m-0 gap-6 flex-col lg:flex-row"
>
<div
class="flex h-16 w-16 items-center justify-center flex-shrink-0 rounded-md border border-n-weak shadow-sm bg-n-alpha-3 dark:bg-n-alpha-2"
>
<img :src="STRIPE_LOGO" alt="Stripe" class="w-12 dark:hidden" />
<img
:src="STRIPE_LOGO_DARK"
alt="Stripe"
class="w-12 hidden dark:block"
/>
</div>
<div>
<h3 class="mb-1 text-heading-1 text-n-slate-12">
{{ $t('STRIPE_INTEGRATION.TITLE') }}
</h3>
<p class="text-n-slate-11 text-body-main">
{{ $t('STRIPE_INTEGRATION.DESCRIPTION') }}
</p>
</div>
</div>
<Button
:label="$t('STRIPE_INTEGRATION.CONNECT')"
:disabled="busy"
faded
blue
@click="connect"
/>
</div>
</div>
</template>
</SettingsLayout>
</template>

View File

@@ -8,6 +8,7 @@ import Webhook from './Webhooks/Index.vue';
import DashboardApps from './DashboardApps/Index.vue';
import Slack from './Slack.vue';
import Linear from './Linear.vue';
import Stripe from './Stripe.vue';
import Notion from './Notion.vue';
import Shopify from './Shopify.vue';
@@ -92,6 +93,14 @@ export default {
},
props: route => ({ code: route.query.code }),
},
{
path: 'stripe',
name: 'settings_integrations_stripe',
component: Stripe,
meta: {
permissions: ['administrator'],
},
},
{
path: 'notion',
name: 'settings_integrations_notion',

View File

@@ -0,0 +1,222 @@
import { mount, flushPromises } from '@vue/test-utils';
import Stripe from '../Stripe.vue';
import StripeAPI from 'dashboard/api/integrations/stripe';
import IntegrationsAPI from 'dashboard/api/integrations';
import messages from 'dashboard/i18n/locale/en/integrations.json';
const mocks = vi.hoisted(() => ({
commit: vi.fn(),
enabled: true,
query: {},
installationName: undefined,
}));
vi.mock('vue-router', () => ({ useRoute: () => ({ query: mocks.query }) }));
vi.mock(
'dashboard/routes/dashboard/settings/components/BaseSettingsHeader.vue',
() => ({
default: { template: '<header />' },
})
);
vi.mock('dashboard/composables/store', async () => {
const { computed, reactive } = await import('vue');
return {
useStore: () => ({ commit: mocks.commit }),
useFunctionGetter: () => {
const integration = reactive({ enabled: mocks.enabled });
mocks.commit.mockImplementation((type, data) => {
if (type === 'integrations/DELETE_INTEGRATION') {
Object.assign(integration, data);
}
});
return computed(() => integration);
},
useMapGetter: () =>
computed(() => ({ installationName: mocks.installationName })),
};
});
vi.mock('dashboard/api/integrations/stripe', () => ({
default: { get: vi.fn(), connect: vi.fn(), disconnect: vi.fn() },
}));
vi.mock('dashboard/api/integrations', () => ({
default: { get: vi.fn() },
}));
describe('Stripe integration settings', () => {
let wrapper;
const global = {
stubs: {
SettingsLayout: {
template: '<div><slot name="header"/><slot name="body"/></div>',
},
BaseSettingsHeader: true,
},
};
beforeEach(() => {
mocks.enabled = true;
mocks.query = {};
mocks.installationName = undefined;
IntegrationsAPI.get.mockResolvedValue({
data: { payload: [{ id: 'stripe', enabled: true }] },
});
StripeAPI.get.mockResolvedValue({
data: {
account_id: 'acct_test',
connected_at: '2026-09-10T00:00:00Z',
mode: 'sandbox',
},
});
});
afterEach(() => wrapper?.unmount());
it.each(['Acme Support', undefined])(
'uses installation branding in disconnect copy: %s',
async installationName => {
mocks.installationName = installationName;
wrapper = mount(Stripe, {
global: {
...global,
mocks: {
$t: key => messages.STRIPE_INTEGRATION[key.split('.')[1]] || key,
},
},
});
await flushPromises();
const brand = installationName || 'Chatwoot';
expect(wrapper.get('button').text()).toBe(`Disconnect from ${brand}`);
expect(wrapper.text()).toContain(
`Disconnecting removes the saved credentials from ${brand}.`
);
if (installationName) expect(wrapper.text()).not.toContain('Chatwoot');
}
);
it('shows connected account details and a sandbox dashboard link', async () => {
wrapper = mount(Stripe, { global });
await flushPromises();
expect(mocks.commit).toHaveBeenCalledWith('integrations/SET_INTEGRATIONS', [
{ id: 'stripe', enabled: true },
]);
expect(wrapper.text()).toContain('acct_test');
expect(wrapper.text()).toContain('STRIPE_INTEGRATION.CONNECTED');
expect(wrapper.get('a').attributes('href')).toBe(
'https://dashboard.stripe.com/acct_test/test/dashboard'
);
expect(wrapper.get('a').attributes('rel')).toBe('noopener noreferrer');
});
it('does not request account details when disconnected', async () => {
mocks.enabled = false;
wrapper = mount(Stripe, { global });
await flushPromises();
expect(StripeAPI.get).not.toHaveBeenCalled();
expect(wrapper.get('button').text()).toContain(
'STRIPE_INTEGRATION.CONNECT'
);
expect(wrapper.find('a').exists()).toBe(false);
});
it('allows disconnecting an unavailable integration without showing Connect', async () => {
mocks.enabled = false;
IntegrationsAPI.get.mockResolvedValueOnce({
data: { payload: [{ id: 'stripe', enabled: false, cleanup_only: true }] },
});
wrapper = mount(Stripe, { global });
await flushPromises();
expect(wrapper.text()).toContain('STRIPE_INTEGRATION.UNAVAILABLE');
expect(wrapper.get('button').text()).toContain(
'STRIPE_INTEGRATION.DISCONNECT'
);
IntegrationsAPI.get.mockResolvedValueOnce({ data: { payload: [] } });
await wrapper.get('button').trigger('click');
await flushPromises();
expect(StripeAPI.disconnect).toHaveBeenCalled();
expect(wrapper.find('button').exists()).toBe(false);
});
it('shows live mode and links to the live dashboard', async () => {
StripeAPI.get.mockResolvedValue({
data: {
account_id: 'acct_live',
connected_at: '2026-09-10T00:00:00Z',
mode: 'live',
},
});
wrapper = mount(Stripe, { global });
await flushPromises();
expect(wrapper.text()).toContain('STRIPE_INTEGRATION.LIVE');
expect(wrapper.text()).not.toContain('STRIPE_INTEGRATION.SANDBOX');
expect(wrapper.get('a').attributes('href')).toBe(
'https://dashboard.stripe.com/acct_live/dashboard'
);
});
it('shows a safe error if account details cannot load', async () => {
StripeAPI.get.mockRejectedValue(new Error('private provider details'));
wrapper = mount(Stripe, { global });
await flushPromises();
expect(wrapper.get('[role="alert"]').text()).toContain(
'STRIPE_INTEGRATION.ERROR'
);
expect(wrapper.text()).not.toContain('private provider details');
});
it('shows a failed OAuth callback error', async () => {
mocks.query = { error: 'authorization_failed' };
wrapper = mount(Stripe, { global });
await flushPromises();
expect(wrapper.get('[role="alert"]').exists()).toBe(true);
});
it('does not show Connect when the integrations request fails', async () => {
mocks.enabled = false;
IntegrationsAPI.get.mockRejectedValue(new Error('request failed'));
wrapper = mount(Stripe, { global });
await flushPromises();
expect(wrapper.get('[role="alert"]').text()).toContain(
'STRIPE_INTEGRATION.ERROR'
);
expect(wrapper.find('button').exists()).toBe(false);
expect(StripeAPI.get).not.toHaveBeenCalled();
});
it('stays disconnected when the refresh after deletion fails', async () => {
StripeAPI.disconnect.mockResolvedValue({});
wrapper = mount(Stripe, { global });
await flushPromises();
IntegrationsAPI.get.mockRejectedValue(new Error('request failed'));
await wrapper.get('button').trigger('click');
await flushPromises();
expect(mocks.commit).toHaveBeenCalledWith(
'integrations/DELETE_INTEGRATION',
{
id: 'stripe',
enabled: false,
hooks: [],
}
);
expect(wrapper.text()).not.toContain('acct_test');
expect(wrapper.text()).not.toContain('STRIPE_INTEGRATION.CONNECTED');
expect(wrapper.get('button').text()).toBe('STRIPE_INTEGRATION.CONNECT');
expect(wrapper.find('a').exists()).toBe(false);
expect(wrapper.get('[role="alert"]').exists()).toBe(true);
});
it('keeps the connected account visible when disconnect fails', async () => {
StripeAPI.disconnect.mockRejectedValue(new Error('request failed'));
wrapper = mount(Stripe, { global });
await flushPromises();
await wrapper.get('button').trigger('click');
await flushPromises();
expect(StripeAPI.disconnect).toHaveBeenCalledOnce();
expect(mocks.commit).not.toHaveBeenCalledWith(
'integrations/DELETE_INTEGRATION',
expect.anything()
);
expect(wrapper.text()).toContain('acct_test');
expect(wrapper.get('[role="alert"]').exists()).toBe(true);
});
});

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