Compare commits

...

24 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
Pranav
ed7b122d27 feat(slack): refresh thread avatars and label Captain and super admin senders (#16141)
Messages Chatwoot posts into a Slack thread now show who sent them more
clearly:

- Captain's replies are labelled **(Captain)** and use the Captain logo
instead of the generic bot one.
- Contact, agent, bot and system messages get new, sharper avatars in
one consistent style.
- Agents who are also super admins are now labelled **(Agent)**; they
were shown as **(Bot)**.

This is split out of #16129. The borders for private notes and activity
messages are in #16142; the two can merge in either order.

## How to test

1. Connect the Slack integration and start a conversation so a thread is
created in the channel.
2. Let Captain reply in the conversation: in Slack the message shows as
`<assistant name> (Captain)` with the Captain logo. A reply from another
agent bot shows as `(Bot)` with the bot avatar.
3. From the dashboard, reply as an agent without a profile picture, and
send a message as a contact: each shows its new default avatar in the
thread. An agent with a profile picture still shows their own.
4. Reply from the dashboard as an agent who is also a super admin: the
message is labelled `(Agent)`.
5. Resolve or reassign the conversation: the activity message appears in
the thread under **System** with the new system avatar.

Slack caches avatars per message, so only messages posted after this
change show the new ones.

## What changed

- `SendOnSlackService` maps each sender label to an avatar file
explicitly. The avatars live under new file names in
`public/integrations/slack/` so Slack fetches them fresh; `user.png`
stays (redrawn) because the Dyte integration uses it as its default
avatar.
- Agents without a profile picture now get the agent avatar. They were
pointed at `agent.png`, which does not exist.
2026-10-05 16:35:36 -07:00
Pranav
b0559b0234 refactor(slack): extract webhook signature verification into a shared verifier (#16140)
Moves Slack request signature verification out of the events webhook
controller into `Integrations::Slack::SignatureVerifier`, so other Slack
endpoints can reuse it. There is no change in behaviour for the events
webhook.

This is split out of #16129, which adds a second Slack endpoint
(interactive button clicks) that needs the same check.

## What changed

- New `Integrations::Slack::SignatureVerifier` with the signing secret
lookup and the signature check, moved as they were from
`Api::V1::Integrations::WebhooksController`.
- The events webhook controller calls the verifier. It still skips
verification with a warning when no signing secret is configured, and
still returns `401` for a missing, stale or invalid signature.
- `SignatureVerifier.valid?` returns `false` when no signing secret is
configured. The events webhook never reaches that case because it checks
for the secret first; it is there so a caller that requires a secret can
use `valid?` alone.
2026-10-05 16:24:28 -07:00
Vishnu Narayanan
1202b5a2da fix(monitors): raise call limits and preserve quota retry holds (#16132)
Conversation monitors now allow **10,000,000 calls per UTC month** and
**500,000 calls per UTC day**, per account. Allowances count attempted
provider requests, including preview calls, and no longer depend on
estimated token lengths. Pending evaluations retain their quota holds
when new messages arrive and resume after the applicable reset without
retrying ahead of a provider cooldown.

## What changed

- Enforce both call limits using persisted daily usage under the account
lock, so concurrent workers cannot exceed the remaining allowance. Each
attempted provider batch consumes one call, even when it evaluates
multiple monitors or returns a provider error.
- Remove the daily token reservation and reconciliation, which mixed
JSON request bytes with provider input tokens. Keep request-size limits
and provider token/cost telemetry.
- Preserve daily and monthly quota holds across new conversation input.
Spread retries over up to four hours after the later of the quota reset
and the longest provider cooldown recorded across failed batches.
- Report when the current monthly allowance was reached, ignoring
timestamps from the previous 100,000-call allowance.
- Document the RubyLLM bundle-audit exceptions for the pinned Ruby 3.4.4
runtime, with reassessment required when the runtime or supported
dependency versions change.

## Quota logging

When a committed call reaches the daily or monthly account cap, emit one
structured `conversation_monitor_limit_reached` log event for that
period. It includes `account_id`, `period` (`daily` or `monthly`),
`used`, `limit`, and the UTC reset timestamp `resets_at`. Rolled-back
calls and subsequent blocked retries do not emit events. Reaching both
caps in the same call emits one event for each period.

The event uses the existing Rails log forwarding path. In New Relic,
group events by account and period with:

```sql
FROM Log
WITH aparse(message, '%"account_id":*,%') AS account_id,
     aparse(message, '%"period":"*"%') AS period
SELECT count(*)
WHERE message LIKE '%conversation_monitor_limit_reached%'
FACET account_id, period
SINCE 7 days ago
LIMIT MAX
```

## Closes

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

## How to reproduce

1. Exhaust an account's daily allowance, then receive additional
conversation messages before the next UTC midnight. Previously, each
message could pull the held work into an early retry and accumulate
another job at the reset. The work now retains its saved retry time
while recording the new input.
2. Have an earlier provider batch return a long `Retry-After`, then
exhaust the daily or monthly allowance before the next batch. The saved
retry now honors both the allowance reset and the longer provider
cooldown before adding jitter.

## Rollout

Existing quota holds retain their saved retry times; the increased
allowances apply when work next reserves a call. Already-scheduled jobs
from before deployment will still run once.

---------

Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
2026-10-05 15:48:37 -07:00
Muhsin Keloth
12ce7b73cc feat(shopify): complete merchant onboarding from the app store (#13550)
Merchants installing Chatwoot from Shopify can create an account or sign
in to an eligible existing workspace, then continue to Shopify billing
setup. The installation destination follows them through email
confirmation, invitation acceptance, password reset, Google sign-in,
SAML, and MFA.

This is the final Shopify onboarding PR. Dashboard connections and the
installation foundation have already merged in #16084 and #16085; this
PR targets `develop` and completes the App Store signup and
authentication flows.

### Closes

- Fixes
https://linear.app/chatwoot/issue/CW-6500/support-shopify-app-store-install-flow-for-merchant-initiated
- Fixes
https://linear.app/chatwoot/issue/PLA-170/resolve-shopify-app-store-billing-rejection

### Things to know

- Shopify remains behind its feature flag. Shared signup, login,
confirmation, password-reset, and account-routing code changes in this
PR still require regression coverage with Shopify disabled.
- App Store onboarding uses Shopify billing. Existing Stripe customers
continue connecting through the dashboard flow.
- Google signup rejection preserves the target workspace. Invalid or
claimed installation tokens return a recoverable error, and invitation
password-setup links preserve the installation continuation.
- Account membership creates notification settings within its
transaction and reuses existing settings when an agent is re-added.
Unrelated agent-cleanup, IMAP entitlement, and shared-plan lookup
changes are excluded.
- No Shopify lookup-index migration is included; those indexes are
deferred.
- Shop-scoped locking and database uniqueness enforcement are deferred.
Concurrent signups using separate pending tokens for the same shop can
create duplicate connections and block later App Store launches.
Database transactions and model validation do not prevent this race. The
installation/uninstall race is also deferred. Both are accepted risks,
not fixed issues:
https://github.com/chatwoot/chatwoot/pull/13550#discussion_r4154926009
and
https://github.com/chatwoot/chatwoot/pull/13550#discussion_r4152556108.
- Configure the Shopify app URLs for the target environment. Webhook
testing requires a reachable callback host.

### How to test

1. With Shopify enabled, install from a development store, create an
account, confirm the email, and complete billing setup with a test plan.
Verify the store is connected to the new workspace.
2. Repeat while signed out of an eligible existing workspace. Verify
workspace selection and the installation destination survive
email/password login, Google, SAML, and MFA.
3. Start with an unconfirmed invited administrator, resend confirmation,
accept the invitation, and set a password. Verify onboarding resumes in
the intended workspace.
4. Exercise password reset and confirmation resends from a Shopify
continuation. Verify the installation token or billing destination and
workspace are preserved.
5. Reject a personal Google signup and retry with the correct identity.
Verify the target workspace is retained. Retry with an expired or
claimed installation token and verify a recoverable error appears
instead of a server error.
6. With Shopify disabled, verify ordinary signup, confirmation,
invitation acceptance, login/logout, password reset/change, Google,
SAML, and MFA. Check existing Stripe billing and account access.

---------

Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-10-05 13:56:55 +04:00
Pranav
351c96a213 feat(companies): add Context.dev enrichment and redesign company pages (#16015) 2026-10-04 08:16:34 +05:30
Sivin Varghese
843385ff3a fix: translate the conversation continuity email body (#16103) 2026-10-01 18:19:23 -07:00
Vishnu Narayanan
80086e5afa fix(conversations): validate assignee and team on conversation create (#16104)
## Description

Conversation create now validates `assignee_id` and `team_id` before
creating the conversation, and returns `422` for values that don't
resolve, in line with how bulk actions handle them. Requests without
these fields behave as before.

Fixes https://linear.app/chatwoot/issue/CW-8347

## Type of change

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

## How Has This Been Tested?

Added request specs for invalid assignee and team values (422, no
conversation created). Existing conversations controller specs pass.

## 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
2026-10-01 12:44:18 -07:00
Muhsin Keloth
170618b9b6 fix(conversations): restrict bulk assignments to the current account (#16098)
Bulk conversation assignments now accept only users and teams belonging
to the current account. Invalid assignment targets return `422` before
the bulk job is queued, so the request does not partially change
conversation status, labels, or assignments.

Existing assignment removal remains supported: `assignee_id: null`,
`team_id: null`, and the dashboard's `team_id: 0` value. Valid
assignments and other bulk actions keep their current behavior.

### How to test

1. As an agent with access to an inbox, select multiple conversations
and assign an agent and team from the current account. Confirm the
assignments update.
2. Remove the agent and team assignments. Confirm both are cleared.
3. Submit a bulk request with an assignment target outside the current
account or a nonexistent target. Expect `422` and no conversation
changes, including when the request also changes status.
4. Bulk-update conversation status without assignment fields. Confirm it
still works.

The pushed commit was checked against a running local Rails server and
Sidekiq worker with synthetic accounts. All 12 HTTP checks passed,
including rejected requests leaving conversations unchanged.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
2026-10-01 11:18:52 -07:00
Muhsin Keloth
480a8ae2e9 fix(shopify): harden dashboard store connections (#16084)
This draft improves Shopify connections started by administrators from
dashboard settings. It adds expiring OAuth state, feature-gate checks,
store-domain validation, and protection against reconnecting an
installation during uninstall cleanup.

Existing signup, login, password reset, and billing behavior remain
unchanged.

Related: https://github.com/chatwoot/chatwoot/pull/13550

### Things to know

- First PR in the Shopify split; merge this before the installation
foundation and signup/authentication changes.
- Shopify remains behind its feature flag.
- Includes dashboard integration and cleanup specs. No database
migrations.
- Browser verification and frontend tests remain outstanding; this draft
is not a merge-readiness confirmation.

### How to test

1. Enable Shopify for a test account and connect a development store
from Settings → Integrations → Shopify.
2. Verify customer orders appear in conversations.
3. Disconnect and reconnect the store.
4. Disable Shopify and verify connection attempts are blocked.
5. Confirm an existing Stripe account retains its billing configuration.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-10-01 10:22:06 +04:00
Sivin Varghese
991741ac14 feat: feature announcements in the dashboard (#16082) 2026-10-01 04:11:14 +05:30
Sony Mathew
1f1146a8d8 feat: trigger automations from monitor matches (#16055)
## Description

Administrators can use an active conversation monitor as an automation
event. They can create a rule from the automation screen or from the
monitor list and report, then see linked rules on the monitor report. A
rule fires when a live public message first makes the conversation
match, whether that message is incoming or outgoing. Replies generated
by another automation remain visible in monitor reporting but cannot
trigger a linked rule; historical scans do not run actions. Pausing or
deleting a monitor disables its linked rules, and resuming the monitor
leaves them disabled until an administrator re-enables them.

## Type of change

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

## Closes

-
[CW-8304](https://linear.app/chatwoot/issue/CW-8304/trigger-automations-from-conversation-monitor-matches)

## How to test

1. Use an account with Reports, Conversation Monitors, and Automations
enabled and a configured monitor provider.
2. Create a monitor, then create an automation with **Monitor matched**
as its event and select that monitor. Confirm only active monitors are
offered.
3. From the monitor list or report, create another automation and
confirm the selected monitor is prefilled. After saving, confirm the
monitor report shows the linked rule.
4. Send a new public incoming or outgoing message that first makes the
monitor match. Confirm the rule's action runs. Send an
automation-generated reply that alone matches the monitor, including
immediately after a customer message, and confirm it records the match
without running the linked rule. Confirm historical scans do not run
actions and the same recorded match is not replayed.
5. Pause the monitor and confirm its linked rules become disabled.
Resume it and confirm the rules stay disabled until explicitly
re-enabled. Deleting a monitor should also disable its linked rules.

## What changed

- Added monitor event validation, account scoping, live activity
attribution, durable delivery records, and action dispatch in
Enterprise.
- Kept automation-generated replies in monitor reporting while excluding
them from the action-eligible message context.
- Preserved live eligibility when a new message is updated before
commit, and isolated delivery inserts so deleting a linked rule cannot
discard the monitor match.
- Added the monitor event picker, rule status and search, monitor
shortcuts, and linked rule list in the dashboard.
- Added coverage for API validation, delivery behavior, lifecycle
changes, and frontend selection and navigation.

## Checklist

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have added tests that prove the feature works
- [x] New and existing focused unit tests pass locally with my changes


## CI follow-up

- Extended the automation composable spec to cover the monitor event.

---------

Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-09-30 15:24:21 -07:00
Tanmay Deep Sharma
26ddaba230 feat(contacts): include contact_type in contact webhook and live-update payloads (#16089)
Webhook payloads and dashboard live updates for a contact now carry its
type: `visitor`, `lead` or `customer`. Since #16077 a visitor's first
conversation sends a `contact_updated` event whose `changed_attributes`
names `contact_type`, but the payload itself did not carry the field.
Now it does, so a consumer can read the new type from the event instead
of fetching the contact again.

## Closes

Part of
[CW-8316](https://linear.app/chatwoot/issue/CW-8316/contacts-contact-type-stale-contact-cleanup-and-opensearch-migration).
Follows up on the review of #16077.

## How to test

- Add an account webhook subscribed to `contact_created` and
`contact_updated`.
- Open the widget on a site as a new visitor and send a message. The
`contact_updated` delivery carries `"contact_type": "lead"`, and its
`changed_attributes` shows the change from `visitor`.
- Create a contact from the dashboard. The `contact_created` delivery
carries `"contact_type": "lead"`.
- Keep the contacts page open in the dashboard while a contact changes.
The live update for that contact carries `contact_type` too.

## What changed

- `Contact#webhook_data` and `Contact#push_event_data` gain
`contact_type`. Both are additive: no existing field changes.
- The REST contact payload is unchanged.
2026-09-30 14:18:52 -07:00
Sivin Varghese
c29e79ca00 feat: collapse the reply box to give more room for reading (#16074) 2026-09-30 23:54:40 +05:30
527 changed files with 17833 additions and 7060 deletions

View File

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

View File

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

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

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

View File

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

View File

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

View File

@@ -31,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

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

@@ -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
@@ -84,17 +85,25 @@ class DashboardController < ActionController::Base
WHATSAPP_APP_ID: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''),
IS_ENTERPRISE: ChatwootApp.enterprise?,
IS_COMPANY_ENRICHMENT_ENABLED: ChatwootApp.enterprise? && GlobalConfigService.load('CONTEXT_DEV_API_KEY', nil).present?,
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
GIT_SHA: GIT_HASH,
ALLOWED_LOGIN_METHODS: allowed_login_methods,
ACTIVE_PLATFORM_BANNERS: active_platform_banners
ACTIVE_PLATFORM_BANNERS: active_platform_banners,
ACTIVE_FEATURE_ANNOUNCEMENTS: active_feature_announcements
}
end
def active_platform_banners
return [] unless ChatwootApp.chatwoot_cloud?
PlatformBanner.active.order(created_at: :desc).as_json(only: %i[id banner_message banner_type updated_at])
PlatformBanner.active.not_feature_announcement.order(created_at: :desc).as_json(only: %i[id banner_message banner_type updated_at])
end
def active_feature_announcements
return [] unless ChatwootApp.chatwoot_cloud?
PlatformBanner.active.feature_announcement.order(created_at: :desc).as_json(only: %i[id title banner_message video_url updated_at])
end
def allowed_login_methods

View File

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

View File

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

View File

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

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

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

View File

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

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

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

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

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

View File

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

View File

@@ -66,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

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

View File

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

View File

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

View File

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

View File

@@ -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

@@ -4,8 +4,10 @@ import { useI18n } from 'vue-i18n';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { useExactTimestamp } from 'shared/composables/useExactTimestamp';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import { getCompanyProfile } from 'dashboard/components-next/Companies/helpers/companyHelper';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Flag from 'dashboard/components-next/flag/Flag.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
@@ -15,6 +17,8 @@ const props = defineProps({
contactsCount: { type: Number, default: 0 },
avatarUrl: { type: String, default: '' },
lastActivityAt: { type: [String, Number], default: null },
description: { type: String, default: '' },
additionalAttributes: { type: Object, default: () => ({}) },
});
const emit = defineEmits(['showCompany']);
@@ -27,13 +31,36 @@ const onClickViewDetails = () => emit('showCompany', props.id);
const displayName = computed(() => props.name || t('COMPANIES.UNNAMED'));
const avatarSource = computed(() => props.avatarUrl || null);
const details = computed(() => {
const { industry, employees, location, countryCode } = getCompanyProfile(
props.additionalAttributes
);
const contactsCount = Number(props.contactsCount || 0);
const hasContacts = computed(() => Number(props.contactsCount || 0) > 0);
const contactsCountLabel = computed(() =>
t('COMPANIES.CONTACTS_COUNT', { n: Number(props.contactsCount || 0) })
);
return [
props.domain && {
key: 'domain',
icon: 'i-lucide-globe',
label: props.domain,
},
industry && {
key: 'industry',
icon: 'i-lucide-building-2',
label: industry,
},
employees && {
key: 'employees',
icon: 'i-lucide-users',
label: t('COMPANIES.CARD.EMPLOYEES', { count: employees }),
},
location && { key: 'location', countryCode, label: location },
contactsCount > 0 && {
key: 'contacts',
icon: 'i-lucide-contact',
label: t('COMPANIES.CONTACTS_COUNT', { n: contactsCount }),
},
].filter(Boolean);
});
const formattedLastActivityAt = computed(() => {
if (!props.lastActivityAt) return '';
@@ -42,52 +69,66 @@ const formattedLastActivityAt = computed(() => {
</script>
<template>
<CardLayout layout="row" @click="onClickViewDetails">
<div class="flex items-center justify-start flex-1 gap-4 cursor-pointer">
<div
class="flex items-start justify-between gap-4 py-4 cursor-pointer group max-sm:grid max-sm:grid-cols-[auto_minmax(0,1fr)_auto] max-sm:gap-x-3 max-sm:gap-y-1"
@click="onClickViewDetails"
>
<div class="flex items-start flex-1 min-w-0 gap-3 max-sm:contents">
<Avatar
:username="displayName"
:src="avatarSource"
class="shrink-0"
:name="name"
:size="42"
:name="displayName"
:src="avatarUrl"
class="shrink-0 max-sm:row-span-3"
:size="36"
hide-offline-status
/>
<div class="flex flex-col gap-0.5 flex-1 min-w-0">
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 min-w-0">
<span class="text-base font-medium truncate text-n-slate-12">
{{ displayName }}
</span>
<span
v-if="hasContacts"
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 truncate"
>
<Icon icon="i-lucide-contact" size="size-3.5 text-n-slate-11" />
{{ contactsCountLabel }}
</span>
</div>
<div class="flex items-center justify-between gap-3">
<div class="flex items-center min-w-0">
<div class="flex flex-col flex-1 min-w-0 gap-1 max-sm:contents">
<span
class="block truncate text-heading-3 text-n-slate-12 group-hover:text-n-blue-11 max-sm:col-start-2"
>
{{ displayName }}
</span>
<p
v-if="description"
class="mb-0 text-n-slate-11 text-body-main line-clamp-2 sm:line-clamp-1 max-sm:col-start-2 max-sm:col-span-2"
>
{{ description }}
</p>
<div
v-if="details.length"
class="overflow-hidden max-sm:col-start-2 max-sm:col-span-2"
>
<div class="flex flex-wrap gap-y-1 -ms-[calc(0.75rem+1px)]">
<span
v-if="domain"
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 truncate cursor-text"
@click.stop
v-for="detail in details"
:key="detail.key"
class="inline-flex items-center min-w-0 gap-1.5 text-body-main text-n-slate-11 max-w-full me-3 before:content-[''] before:w-px before:h-3 before:shrink-0 before:bg-n-slate-6 before:me-1.5"
:title="detail.label"
>
<Icon icon="i-lucide-globe" size="size-3.5 text-n-slate-11" />
<span class="truncate">{{ domain }}</span>
<Flag
v-if="detail.countryCode"
:country="detail.countryCode"
class="size-3.5 shrink-0"
/>
<Icon
v-else-if="detail.icon"
:icon="detail.icon"
class="size-3.5 shrink-0 text-n-slate-10"
/>
<span class="truncate sm:max-w-60">{{ detail.label }}</span>
</span>
</div>
<span
v-if="lastActivityAt"
v-tooltip.top="{
content: exactTimestamp(lastActivityAt),
delay: { show: 500, hide: 0 },
}"
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 flex-shrink-0"
>
{{ formattedLastActivityAt }}
</span>
</div>
</div>
</div>
</CardLayout>
<span
v-if="lastActivityAt"
v-tooltip.top="{
content: exactTimestamp(lastActivityAt),
delay: { show: 500, hide: 0 },
}"
class="flex-shrink-0 text-sm text-n-slate-11 leading-[1.3125rem] max-sm:col-start-3 max-sm:row-start-1"
>
{{ formattedLastActivityAt }}
</span>
</div>
</template>

View File

@@ -1,125 +0,0 @@
<script setup>
import { ref, useSlots } from 'vue';
import { vOnClickOutside } from '@vueuse/components';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
breadcrumbItems: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['back']);
const slots = useSlots();
const isSidebarOpen = ref(false);
const toggleSidebar = () => {
isSidebarOpen.value = !isSidebarOpen.value;
};
const closeMobileSidebar = () => {
if (!isSidebarOpen.value) {
return;
}
isSidebarOpen.value = false;
};
</script>
<template>
<section
class="flex w-full h-full overflow-hidden justify-evenly bg-n-surface-1"
>
<div
class="flex flex-col w-full h-full transition-all duration-300 ltr:2xl:ml-56 rtl:2xl:mr-56"
>
<header class="sticky top-0 z-10 px-6 3xl:px-0">
<div class="w-full mx-auto max-w-[40.625rem]">
<div
class="flex flex-col xs:flex-row items-start xs:items-center justify-between w-full py-7 gap-2"
>
<Breadcrumb :items="breadcrumbItems" @click="emit('back')" />
</div>
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto 3xl:px-px">
<div class="w-full py-4 mx-auto max-w-[40.625rem]">
<slot />
</div>
</main>
</div>
<div
v-if="slots.sidebar"
class="hidden lg:flex flex-col min-w-52 w-full max-w-md border-l border-n-weak bg-n-solid-2"
>
<div class="shrink-0">
<slot name="sidebarHeader" />
</div>
<div class="flex-1 overflow-y-auto pb-6 pt-3">
<slot name="sidebar" />
</div>
</div>
<div
v-if="slots.sidebar"
class="lg:hidden fixed top-0 ltr:right-0 rtl:left-0 h-full z-50 flex justify-end transition-all duration-200 ease-in-out"
:class="isSidebarOpen ? 'w-full' : 'w-16'"
>
<div
v-on-click-outside="[
closeMobileSidebar,
{ ignore: ['#details-sidebar-content'] },
]"
class="flex items-start p-1 w-fit h-fit relative order-1 xs:top-24 top-28 transition-all bg-n-solid-2 border border-n-weak duration-500 ease-in-out"
:class="[
isSidebarOpen
? 'justify-end ltr:rounded-l-full rtl:rounded-r-full ltr:rounded-r-none rtl:rounded-l-none'
: 'justify-center rounded-full ltr:mr-6 rtl:ml-6',
]"
>
<Button
ghost
slate
sm
class="!rounded-full rtl:rotate-180"
:class="{ 'bg-n-alpha-2': isSidebarOpen }"
:icon="
isSidebarOpen
? 'i-lucide-panel-right-close'
: 'i-lucide-panel-right-open'
"
data-details-sidebar-toggle
@click="toggleSidebar"
/>
</div>
<Transition
enter-active-class="transition-transform duration-200 ease-in-out"
leave-active-class="transition-transform duration-200 ease-in-out"
enter-from-class="ltr:translate-x-full rtl:-translate-x-full"
enter-to-class="ltr:translate-x-0 rtl:-translate-x-0"
leave-from-class="ltr:translate-x-0 rtl:-translate-x-0"
leave-to-class="ltr:translate-x-full rtl:-translate-x-full"
>
<div
v-if="isSidebarOpen"
id="details-sidebar-content"
class="order-2 w-[85%] sm:w-[50%] flex flex-col bg-n-solid-2 ltr:border-l rtl:border-r border-n-weak shadow-lg"
>
<div class="shrink-0">
<slot name="sidebarHeader" />
</div>
<div class="flex-1 overflow-y-auto pb-6 pt-3">
<slot name="sidebar" />
</div>
</div>
</Transition>
</div>
</section>
</template>

View File

@@ -1,6 +1,5 @@
<script setup>
import Input from 'dashboard/components-next/input/Input.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import CompanySearchInput from 'dashboard/components-next/Companies/CompanySearchInput.vue';
import CompanySortMenu from './components/CompanySortMenu.vue';
import CompanyMoreActions from './components/CompanyMoreActions.vue';
@@ -18,31 +17,19 @@ const emit = defineEmits(['search', 'update:sort', 'create']);
<template>
<header class="sticky top-0 z-10 px-6">
<div
class="flex items-start sm:items-center justify-between w-full py-6 gap-2 mx-auto max-w-5xl"
class="flex items-center justify-between w-full py-6 gap-4 mx-auto max-w-5xl"
>
<span class="text-xl font-medium truncate text-n-slate-12">
<span class="text-xl font-medium shrink-0 text-n-slate-12">
{{ headerTitle }}
</span>
<div class="flex items-center flex-col sm:flex-row flex-shrink-0 gap-4">
<div v-if="showSearch" class="flex items-center gap-2 w-full">
<Input
:model-value="searchValue"
type="search"
:placeholder="$t('COMPANIES.SEARCH_PLACEHOLDER')"
:custom-input-class="[
'h-8 [&:not(.focus)]:!border-transparent bg-n-alpha-2 dark:bg-n-solid-1 ltr:!pl-8 !py-1 rtl:!pr-8',
]"
class="w-full"
@input="emit('search', $event.target.value)"
>
<template #prefix>
<Icon
icon="i-lucide-search"
class="absolute -translate-y-1/2 text-n-slate-11 size-4 top-1/2 ltr:left-2 rtl:right-2"
/>
</template>
</Input>
</div>
<div class="flex items-center min-w-0 gap-2 sm:gap-4">
<CompanySearchInput
v-if="showSearch"
:model-value="searchValue"
:placeholder="$t('COMPANIES.SEARCH_PLACEHOLDER')"
class="flex-1 min-w-0"
@update:model-value="emit('search', $event)"
/>
<div class="flex items-center flex-shrink-0 gap-2">
<CompanySortMenu
:active-sort="activeSort"

View File

@@ -38,7 +38,7 @@ const handleAction = ({ action }) => {
<DropdownMenu
v-if="showActionsDropdown"
:menu-items="menuItems"
class="ltr:right-0 rtl:left-0 mt-1 w-52 top-full"
class="end-0 mt-1 w-52 top-full"
@action="handleAction($event)"
/>
</div>

View File

@@ -95,7 +95,7 @@ const handleOrderChange = value => {
<div
v-if="isMenuOpen"
v-on-clickaway="() => (isMenuOpen = false)"
class="absolute top-full mt-1 ltr:-right-32 rtl:-left-32 sm:ltr:right-0 sm:rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
class="absolute top-full mt-1 end-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
>
<div class="flex items-center justify-between gap-2">
<span class="text-sm text-n-slate-12">

View File

@@ -48,7 +48,7 @@ const updateCurrentPage = page => {
current-page-info="COMPANIES_LAYOUT.PAGINATION_FOOTER.SHOWING"
:current-page="currentPage"
:total-items="totalItems"
:items-per-page="25"
:items-per-page="50"
class="max-w-[67rem]"
@update:current-page="updateCurrentPage"
/>

View File

@@ -1,31 +1,17 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { debounce } from '@chatwoot/utils';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
const props = defineProps({
company: {
type: Object,
default: () => ({}),
},
contacts: {
type: Array,
default: () => [],
},
meta: {
type: Object,
default: () => ({}),
},
isLoading: {
type: Boolean,
default: false,
},
isBusy: {
type: Boolean,
default: false,
@@ -47,39 +33,15 @@ const props = defineProps({
const emit = defineEmits([
'cancelContactSelection',
'confirmContactSelection',
'removeContact',
'search',
'selectContact',
'update:currentPage',
]);
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const selectedContactId = ref(null);
const searchQuery = ref('');
const hasContacts = computed(() => props.contacts.length > 0);
const currentPage = computed(() => Number(props.meta?.page || 1));
const totalContacts = computed(() => Number(props.meta?.totalCount || 0));
const linkedContactIds = computed(
() => new Set(props.contacts.map(contact => Number(contact.id)))
);
const showPaginationFooter = computed(
() => hasContacts.value && totalContacts.value > props.contacts.length
);
const openContact = contactId => {
router.push({
name: 'contacts_edit',
params: {
accountId: route.params.accountId,
contactId,
},
});
};
const contactMeta = contact =>
[contact.email, contact.phoneNumber].filter(Boolean).join(' • ');
@@ -88,11 +50,7 @@ const contactName = contact =>
const contactOptions = computed(() =>
props.searchResults
.filter(
contact =>
!contact.linkedToCurrentCompany &&
!linkedContactIds.value.has(Number(contact.id))
)
.filter(contact => !contact.linkedToCurrentCompany)
.map(contact => ({
value: contact.id,
label: [contactName(contact), contact.email, contact.phoneNumber]
@@ -169,16 +127,11 @@ const handleContactSelect = contactId => {
</script>
<template>
<div class="flex flex-col gap-6 px-6 pb-8">
<div class="flex flex-col gap-6">
<div v-if="!selectedContact" class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<label class="text-base text-n-slate-12">
{{ t('COMPANIES.DETAIL.CONTACTS.ACTIONS.ADD') }}
</label>
<span class="text-sm text-n-slate-11">
{{ t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.DESCRIPTION') }}
</span>
</div>
<span class="text-body-main text-n-slate-11">
{{ t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.DESCRIPTION') }}
</span>
<ComboBox
use-api-results
:model-value="selectedContactId"
@@ -230,7 +183,6 @@ const handleContactSelect = contactId => {
:name="row.avatarName"
:src="row.avatarSrc"
:size="32"
rounded-full
hide-offline-status
/>
<div class="flex flex-col w-full min-w-0 gap-1">
@@ -268,87 +220,5 @@ const handleContactSelect = contactId => {
/>
</div>
</div>
<div class="flex flex-col gap-3">
<div class="flex items-center justify-between gap-3">
<h4 class="text-sm font-medium text-n-slate-12">
{{ t('COMPANIES.DETAIL.SIDEBAR.TABS.CONTACTS') }}
</h4>
<span v-if="hasContacts" class="text-xs tabular-nums text-n-slate-11">
{{ t('COMPANIES.CONTACTS_COUNT', { n: totalContacts }) }}
</span>
</div>
<div
v-if="isLoading && !hasContacts"
class="py-8 text-sm text-center rounded-xl border border-dashed border-n-weak text-n-slate-11"
>
{{ t('COMPANIES.DETAIL.CONTACTS.LOADING') }}
</div>
<div
v-else-if="!hasContacts"
class="py-8 px-4 text-sm text-center rounded-xl border border-dashed border-n-strong text-n-slate-11"
>
{{ t('COMPANIES.DETAIL.CONTACTS.EMPTY') }}
</div>
<div v-else class="flex flex-col divide-y divide-n-weak">
<div
v-for="contact in contacts"
:key="contact.id"
class="flex items-center gap-2 py-3 group/contact"
>
<button
type="button"
class="flex items-center flex-1 min-w-0 !p-0 gap-3 text-start rounded-lg transition-colors text-n-slate-12 hover:text-n-blue-11 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-n-brand focus-visible:ring-offset-2 focus-visible:ring-offset-n-background"
@click="openContact(contact.id)"
>
<Avatar
:name="contactName(contact)"
:src="contact.thumbnail"
:size="32"
rounded-full
hide-offline-status
/>
<div class="min-w-0 space-y-0.5">
<span
class="text-sm font-medium leading-5 truncate text-n-slate-12"
>
{{ contactName(contact) }}
</span>
<p
v-if="contactMeta(contact)"
class="text-sm leading-5 truncate text-n-slate-11"
>
{{ contactMeta(contact) }}
</p>
</div>
</button>
<Button
icon="i-lucide-unlink"
color="slate"
variant="ghost"
size="xs"
class="shrink-0 opacity-70 transition-opacity sm:opacity-0 sm:group-hover/contact:opacity-100 sm:focus-visible:opacity-100"
:disabled="isBusy"
:title="t('COMPANIES.DETAIL.CONTACTS.ACTIONS.REMOVE')"
:aria-label="t('COMPANIES.DETAIL.CONTACTS.ACTIONS.REMOVE')"
@click.stop="emit('removeContact', contact.id)"
/>
</div>
</div>
<PaginationFooter
v-if="showPaginationFooter"
current-page-info="CONTACTS_LAYOUT.PAGINATION_FOOTER.SHOWING"
:current-page="currentPage"
:total-items="totalContacts"
:items-per-page="15"
class="!px-0 before:hidden bg-transparent"
@update:current-page="emit('update:currentPage', $event)"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,125 @@
<script setup>
import { computed, onMounted, useTemplateRef } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import { useStore } from 'dashboard/composables/store';
import Button from 'dashboard/components-next/button/Button.vue';
import SingleSelect from 'dashboard/components-next/filter/inputs/SingleSelect.vue';
import { useConversationFilterContext } from 'dashboard/components-next/filter/provider';
const filters = defineModel({ type: Array, default: () => [] });
const SIMPLE_FILTER_KEYS = [
'status',
'assignee_id',
'inbox_id',
'team_id',
'priority',
'labels',
];
const { t } = useI18n();
const store = useStore();
const { attributeFilterTypes } = useConversationFilterContext();
const [showFilters, toggleFilters] = useToggle();
const toggleButton = useTemplateRef('toggleButton');
const closeOnClickOutside = [
() => toggleFilters(false),
{ ignore: [toggleButton] },
];
// Assignee options need agents, which the dashboard doesn't load globally.
onMounted(() => store.dispatch('agents/get'));
const simpleFilters = computed(() =>
SIMPLE_FILTER_KEYS.map(key =>
attributeFilterTypes.value.find(type => type.attributeKey === key)
)
.filter(Boolean)
.map(type => ({
key: type.attributeKey,
label: type.label,
options: type.options.filter(option => option.id !== 'all'),
}))
);
const activeCount = computed(() => filters.value.length);
const selectedOption = key =>
filters.value.find(filter => filter.attributeKey === key)?.values?.[0];
const setFilter = (key, option) => {
const others = filters.value.filter(filter => filter.attributeKey !== key);
filters.value = option
? [
...others,
{
attributeKey: key,
filterOperator: 'equal_to',
values: [option],
queryOperator: 'and',
},
]
: others;
};
const clearFilters = () => {
filters.value = [];
};
</script>
<template>
<div class="relative">
<Button
ref="toggleButton"
:label="
activeCount
? t('COMPANIES.DETAIL.CONVERSATION_FILTERS.BUTTON_WITH_COUNT', {
count: activeCount,
})
: t('COMPANIES.DETAIL.CONVERSATION_FILTERS.BUTTON')
"
icon="i-lucide-list-filter"
slate
:faded="!activeCount"
sm
@click="toggleFilters()"
/>
<div
v-if="showFilters"
v-on-click-outside="closeOnClickOutside"
class="absolute z-40 flex flex-col gap-4 p-4 mt-1 border bg-n-alpha-3 backdrop-blur-[100px] border-n-weak w-80 rounded-xl top-full end-0"
>
<div
v-for="filter in simpleFilters"
:key="filter.key"
class="flex items-center justify-between gap-3"
>
<span class="text-sm truncate text-n-slate-12">
{{ filter.label }}
</span>
<SingleSelect
:model-value="selectedOption(filter.key)"
:options="filter.options"
:placeholder="t('COMPANIES.DETAIL.CONVERSATION_FILTERS.ANY')"
placeholder-icon="i-lucide-chevron-down"
placeholder-trailing-icon
dropdown-max-height="max-h-64"
@update:model-value="option => setFilter(filter.key, option)"
/>
</div>
<Button
v-if="activeCount"
:label="t('FILTER.CLEAR_BUTTON_LABEL')"
variant="link"
color="slate"
size="xs"
class="self-end"
@click="clearFilters"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,109 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import { conversationUrl, frontendURL } from 'dashboard/helper/URLHelper';
import { groupByDay } from 'dashboard/components-next/Companies/helpers/companyHelper';
import Button from 'dashboard/components-next/button/Button.vue';
import ConversationCardExpanded from 'dashboard/components-next/Conversation/ConversationCard/ConversationCardExpanded.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const props = defineProps({
conversations: { type: Array, default: () => [] },
isLoading: { type: Boolean, default: false },
hasMore: { type: Boolean, default: false },
filtered: { type: Boolean, default: false },
});
const emit = defineEmits(['loadMore']);
const { t } = useI18n();
const router = useRouter();
const { accountId } = useAccount();
const contactById = useMapGetter('contacts/getContact');
const inboxById = useMapGetter('inboxes/getInbox');
const groups = computed(() => groupByDay(props.conversations, 'timestamp', t));
const conversationContact = conversation => {
const sender = conversation.meta?.sender || {};
const contact = sender.id ? contactById.value(sender.id) : {};
return contact?.id ? contact : sender;
};
const conversationInbox = conversation =>
inboxById.value(conversation.inbox_id) || {};
const openConversation = (conversation, event) => {
const path = frontendURL(
conversationUrl({ accountId: accountId.value, id: conversation.id })
);
if (event.metaKey || event.ctrlKey) {
window.open(
`${window.chatwootConfig.hostURL}${path}`,
'_blank',
'noopener,noreferrer'
);
return;
}
router.push({ path });
};
</script>
<template>
<section class="flex flex-col gap-4">
<div
v-if="isLoading && !conversations.length"
class="flex justify-center py-12"
>
<Spinner />
</div>
<p
v-else-if="!conversations.length"
class="px-6 py-10 text-sm text-center border border-dashed rounded-xl border-n-weak text-n-slate-11"
>
{{
filtered
? t('COMPANIES.DETAIL.ACTIVITY.NO_MATCHING_CONVERSATIONS')
: t('COMPANIES.DETAIL.ACTIVITY.EMPTY_CONVERSATIONS')
}}
</p>
<div v-else class="flex flex-col gap-6">
<div v-for="group in groups" :key="group.label" class="flex flex-col">
<h3 class="pb-1 text-label-small text-n-slate-10">
{{ group.label }}
</h3>
<ConversationCardExpanded
v-for="conversation in group.items"
:key="conversation.id"
:chat="conversation"
:current-contact="conversationContact(conversation)"
:assignee="conversation.meta?.assignee || {}"
:inbox="conversationInbox(conversation)"
:selectable="false"
conversation-first
show-assignee
show-inbox-name
class="!px-0 hover:!bg-transparent hover:!border-n-slate-3 hover:before:!content-none [&:hover_h4]:!text-n-blue-11"
@click="openConversation(conversation, $event)"
/>
</div>
</div>
<Button
v-if="hasMore && conversations.length"
:label="t('COMPANIES.DETAIL.ACTIVITY.LOAD_MORE')"
variant="faded"
color="slate"
size="sm"
class="self-center"
:is-loading="isLoading"
@click="emit('loadMore')"
/>
</section>
</template>

View File

@@ -80,7 +80,7 @@ onMounted(() => {
</script>
<template>
<div v-if="hasCompanyAttributes" class="flex flex-col gap-6 px-6 py-6">
<div v-if="hasCompanyAttributes" class="flex flex-col gap-6">
<div v-if="!hasNoUsedAttributes" class="flex flex-col gap-2">
<CompanyCustomAttributeItem
v-for="attribute in usedAttributes"
@@ -105,14 +105,12 @@ onMounted(() => {
<div class="flex flex-col gap-3">
<div v-if="!hasNoUnusedAttributes" class="relative">
<span
class="absolute i-lucide-search size-3.5 top-2 ltr:left-3 rtl:right-3"
/>
<span class="absolute i-lucide-search size-3.5 top-2 start-3" />
<input
v-model="searchQuery"
type="search"
:placeholder="t('COMPANIES.DETAIL.ATTRIBUTES.SEARCH_PLACEHOLDER')"
class="w-full h-8 py-2 pl-10 pr-2 text-sm reset-base outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
class="w-full h-8 py-2 ps-10 pe-2 text-sm reset-base outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
/>
</div>
@@ -136,7 +134,7 @@ onMounted(() => {
</div>
</div>
<p v-else class="px-6 py-10 text-sm leading-6 text-center text-n-slate-11">
<p v-else class="text-sm text-n-slate-11">
{{ t('COMPANIES.DETAIL.ATTRIBUTES.EMPTY_STATE') }}
</p>
</template>

View File

@@ -0,0 +1,301 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useAlert } from 'dashboard/composables';
import { useMapGetter } from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import { useAdmin } from 'dashboard/composables/useAdmin';
import { usePolicy } from 'dashboard/composables/usePolicy';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { dynamicTime } from 'shared/helpers/timeHelper';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import SocialProfileLinks from 'dashboard/components-next/social-profiles/SocialProfileLinks.vue';
import { useCompaniesStore } from 'dashboard/stores/companies';
const props = defineProps({
company: { type: Object, required: true },
openConversationsCount: { type: Number, default: 0 },
});
const emit = defineEmits(['edit', 'delete', 'showOpenConversations']);
const DESCRIPTION_CLAMP_LENGTH = 200;
const { t } = useI18n();
const companiesStore = useCompaniesStore();
const { isAdmin } = useAdmin();
const router = useRouter();
const { accountId } = useAccount();
const { shouldShowPaywall } = usePolicy();
const globalConfig = useMapGetter('globalConfig/get');
const upgradeDialogRef = ref(null);
const avatarPreviewUrl = ref('');
const isUploadingAvatar = ref(false);
const isDescriptionExpanded = ref(false);
const showActionsMenu = ref(false);
const uiFlags = computed(() => companiesStore.getUIFlags);
const showRefreshButton = computed(
() =>
isAdmin.value &&
globalConfig.value.isCompanyEnrichmentEnabled &&
Boolean(props.company.domain)
);
const displayName = computed(
() => props.company.name || t('COMPANIES.UNNAMED')
);
const isAvatarBusy = computed(
() =>
isUploadingAvatar.value ||
uiFlags.value.deletingAvatar ||
uiFlags.value.updatingItem
);
const isDescriptionLong = computed(
() => (props.company.description || '').length > DESCRIPTION_CLAMP_LENGTH
);
const socialProfiles = computed(
() => props.company.additionalAttributes?.social_profiles || {}
);
const phone = computed(() => props.company.additionalAttributes?.phone);
const enrichedAt = computed(() => {
const value = props.company.additionalAttributes?.enriched_at;
return value && dynamicTime(new Date(value).getTime() / 1000);
});
const goToBilling = () => {
upgradeDialogRef.value?.close();
router.push({
name: 'billing_settings_index',
params: { accountId: accountId.value },
});
};
const handleRefresh = async () => {
if (shouldShowPaywall(FEATURE_FLAGS.COMPANY_ENRICHMENT)) {
upgradeDialogRef.value?.open();
return;
}
try {
await companiesStore.enrich(props.company.id);
useAlert(t('COMPANIES.DETAIL.PROFILE.MESSAGES.REFRESH_SUCCESS'));
} catch (error) {
// The account's flags can be stale; the server decides on the plan.
if (error.response?.status === 403) {
upgradeDialogRef.value?.open();
return;
}
useAlert(
error.response?.data?.error ||
t('COMPANIES.DETAIL.PROFILE.MESSAGES.REFRESH_ERROR')
);
}
};
const menuItems = computed(() =>
isAdmin.value
? [
{
label: t('COMPANIES.DETAIL.DELETE.BUTTON'),
action: 'delete',
value: 'delete',
icon: 'i-lucide-trash-2',
},
]
: []
);
watch(
() => [props.company.id, props.company.avatarUrl],
() => {
avatarPreviewUrl.value = '';
isDescriptionExpanded.value = false;
}
);
const handleAvatarUpload = async ({ file, url }) => {
avatarPreviewUrl.value = url;
isUploadingAvatar.value = true;
try {
await companiesStore.update({ id: props.company.id, avatar: file });
useAlert(t('COMPANIES.DETAIL.AVATAR.UPLOAD_SUCCESS'));
} catch {
avatarPreviewUrl.value = '';
useAlert(t('COMPANIES.DETAIL.AVATAR.UPLOAD_ERROR'));
} finally {
isUploadingAvatar.value = false;
}
};
const handleAvatarDelete = async () => {
try {
await companiesStore.deleteCompanyAvatar(props.company.id);
avatarPreviewUrl.value = '';
useAlert(t('COMPANIES.DETAIL.AVATAR.DELETE_SUCCESS'));
} catch {
useAlert(t('COMPANIES.DETAIL.AVATAR.DELETE_ERROR'));
}
};
const handleDelete = () => {
showActionsMenu.value = false;
emit('delete');
};
</script>
<template>
<header class="flex flex-col gap-4">
<div class="flex items-start gap-4">
<Avatar
:name="displayName"
:src="avatarPreviewUrl || company.avatarUrl || ''"
:size="48"
:allow-upload="!isAvatarBusy"
class="shrink-0"
hide-offline-status
@upload="handleAvatarUpload"
@delete="handleAvatarDelete"
/>
<div class="flex flex-col flex-1 min-w-0 gap-1.5">
<div class="flex flex-wrap items-center gap-x-2.5 gap-y-1">
<h1 class="break-words text-heading-1 text-n-slate-12">
{{ displayName }}
</h1>
<button
v-if="openConversationsCount"
type="button"
class="inline-flex items-center gap-1.5 px-1.5 py-px rounded-md text-label-small bg-n-amber-3 text-n-amber-11 hover:bg-n-amber-4"
@click="emit('showOpenConversations')"
>
<span class="rounded-full size-1.5 bg-n-amber-9" />
{{
t(
'COMPANIES.DETAIL.HEADER.OPEN_COUNT',
{ count: openConversationsCount },
openConversationsCount
)
}}
</button>
</div>
<div class="flex flex-wrap items-center text-sm gap-x-3 gap-y-1">
<a
v-if="company.domain"
:href="`https://${company.domain}`"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-n-slate-11 hover:text-n-slate-12"
>
{{ company.domain }}
<span class="i-lucide-arrow-up-right size-3.5" />
</a>
<a
v-if="phone"
:href="`tel:${phone}`"
class="inline-flex items-center gap-1 text-n-slate-11 hover:text-n-slate-12"
>
<span class="i-lucide-phone size-3.5" />
<span dir="ltr">{{ phone }}</span>
</a>
<span
v-if="
Object.keys(socialProfiles).length && (company.domain || phone)
"
class="w-px h-3.5 bg-n-weak max-sm:hidden"
/>
<SocialProfileLinks :profiles="socialProfiles" />
</div>
</div>
<div class="flex items-center gap-1 shrink-0">
<Button
v-if="showRefreshButton"
v-tooltip.top="
enrichedAt
? t('COMPANIES.DETAIL.HEADER.REFRESH_WITH_TIME', {
time: enrichedAt,
})
: t('COMPANIES.DETAIL.PROFILE.ACTIONS.REFRESH')
"
icon="i-lucide-refresh-cw"
variant="ghost"
color="slate"
size="sm"
:is-loading="uiFlags.enrichingItem"
@click="handleRefresh"
/>
<Button
v-tooltip.top="t('COMPANIES.DETAIL.HEADER.EDIT')"
icon="i-lucide-pencil"
variant="ghost"
color="slate"
size="sm"
@click="emit('edit')"
/>
<div
v-if="menuItems.length"
v-on-clickaway="() => (showActionsMenu = false)"
class="relative"
>
<Button
icon="i-lucide-ellipsis"
variant="ghost"
color="slate"
size="sm"
@click="showActionsMenu = !showActionsMenu"
/>
<DropdownMenu
v-if="showActionsMenu"
:menu-items="menuItems"
class="mt-1 end-0 w-48 top-full"
@action="handleDelete"
/>
</div>
</div>
</div>
<div
v-if="company.description"
class="flex flex-col items-start max-w-4xl gap-1"
>
<p
class="mb-0 text-body-main text-n-slate-11"
:class="{ 'line-clamp-2': !isDescriptionExpanded }"
>
{{ company.description }}
</p>
<button
v-if="isDescriptionLong"
type="button"
class="p-0 text-label-small text-n-slate-11 hover:text-n-slate-12"
@click="isDescriptionExpanded = !isDescriptionExpanded"
>
{{
isDescriptionExpanded
? t('COMPANIES.DETAIL.HEADER.LESS')
: t('COMPANIES.DETAIL.HEADER.MORE')
}}
</button>
</div>
<p
v-if="isUploadingAvatar || uiFlags.deletingAvatar"
class="text-sm text-n-slate-11"
>
{{ t('COMPANIES.DETAIL.AVATAR.UPDATING') }}
</p>
<Dialog
ref="upgradeDialogRef"
:title="t('COMPANIES.DETAIL.ENRICHMENT_UPGRADE.TITLE')"
:description="t('COMPANIES.DETAIL.ENRICHMENT_UPGRADE.DESCRIPTION')"
:confirm-button-label="t('COMPANIES.DETAIL.ENRICHMENT_UPGRADE.UPGRADE')"
@confirm="goToBilling"
/>
</header>
</template>

View File

@@ -0,0 +1,277 @@
<script setup>
import { computed, reactive, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Button from 'dashboard/components-next/button/Button.vue';
import CompanyCustomAttributes from 'dashboard/components-next/Companies/CompanyDetail/CompanyCustomAttributes.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import SidePanel from 'dashboard/components-next/side-panel/SidePanel.vue';
import SocialProfileInput from 'dashboard/components-next/social-profiles/SocialProfileInput.vue';
import {
socialProfileHandle,
socialProfileUrl,
} from 'dashboard/components-next/social-profiles/socialProfiles';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import { useCompaniesStore } from 'dashboard/stores/companies';
const props = defineProps({
company: { type: Object, required: true },
});
const DESCRIPTION_MAX_LENGTH = 1000;
const SOCIAL_NETWORKS = [
'linkedin',
'x',
'facebook',
'instagram',
'github',
'youtube',
];
const { t } = useI18n();
const companiesStore = useCompaniesStore();
const panelRef = ref(null);
const form = reactive({
name: '',
domain: '',
description: '',
industry: '',
employees: '',
city: '',
country: '',
phone: '',
socialProfiles: {},
});
const initialDetails = ref({});
const isUpdating = computed(() => companiesStore.getUIFlags.updatingItem);
const isFormInvalid = computed(() => !form.name.trim());
const socialLabels = computed(() => ({
linkedin: t('COMPANIES.DETAIL.EDIT.SOCIAL.LINKEDIN'),
x: t('COMPANIES.DETAIL.EDIT.SOCIAL.X'),
facebook: t('COMPANIES.DETAIL.EDIT.SOCIAL.FACEBOOK'),
instagram: t('COMPANIES.DETAIL.EDIT.SOCIAL.INSTAGRAM'),
github: t('COMPANIES.DETAIL.EDIT.SOCIAL.GITHUB'),
youtube: t('COMPANIES.DETAIL.EDIT.SOCIAL.YOUTUBE'),
}));
// Editing industry or employees replaces the enriched value shown and clears its counterpart.
const readDetails = attributes => ({
industry: attributes.sub_industry || attributes.industry || '',
employees: String(
attributes.employee_count || attributes.employee_count_range || ''
),
city: attributes.city || '',
country: attributes.country || '',
phone: attributes.phone || '',
});
const open = () => {
const attributes = props.company.additionalAttributes || {};
const socialProfiles = attributes.social_profiles || {};
initialDetails.value = readDetails(attributes);
Object.assign(form, {
name: props.company.name || '',
domain: props.company.domain || '',
description: props.company.description || '',
...initialDetails.value,
socialProfiles: {
...Object.fromEntries(
SOCIAL_NETWORKS.map(network => [
network,
socialProfileHandle(network, socialProfiles[network]),
])
),
x: socialProfileHandle('x', socialProfiles.x || socialProfiles.twitter),
},
});
panelRef.value?.open();
};
const close = () => panelRef.value?.close();
const changed = field =>
form[field].trim() !== initialDetails.value[field].trim();
const buildAdditionalAttributes = () => {
const attributes = { ...(props.company.additionalAttributes || {}) };
const assign = (key, value) => {
if (value) attributes[key] = value;
else delete attributes[key];
};
if (changed('industry')) {
assign('industry', form.industry.trim());
delete attributes.sub_industry;
}
if (changed('employees')) {
assign('employee_count_range', form.employees.trim());
delete attributes.employee_count;
}
if (changed('country')) delete attributes.country_code;
assign('city', form.city.trim());
assign('country', form.country.trim());
assign('phone', form.phone.trim());
const socialProfiles = { ...attributes.social_profiles };
delete socialProfiles.twitter;
Object.entries(form.socialProfiles).forEach(([network, handle]) => {
const url = socialProfileUrl(network, handle);
if (url) socialProfiles[network] = url;
else delete socialProfiles[network];
});
delete attributes.social_profiles;
if (Object.keys(socialProfiles).length) {
attributes.social_profiles = socialProfiles;
}
return attributes;
};
const handleConfirm = async () => {
if (isFormInvalid.value) return;
try {
await companiesStore.update({
id: props.company.id,
name: form.name.trim(),
domain: form.domain.trim() || null,
description: form.description.trim() || null,
additionalAttributes: buildAdditionalAttributes(),
});
useAlert(t('COMPANIES.DETAIL.PROFILE.MESSAGES.UPDATE_SUCCESS'));
close();
} catch (error) {
useAlert(
error.message || t('COMPANIES.DETAIL.PROFILE.MESSAGES.UPDATE_ERROR')
);
}
};
defineExpose({ open });
</script>
<template>
<SidePanel
ref="panelRef"
width="2xl"
:title="t('COMPANIES.DETAIL.EDIT.TITLE')"
>
<div class="flex flex-col gap-8">
<section class="flex flex-col gap-4">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Input
v-model="form.name"
:label="t('COMPANIES.DETAIL.PROFILE.FIELDS.NAME')"
:disabled="isUpdating"
autofocus
/>
<Input
v-model="form.domain"
:label="t('COMPANIES.DETAIL.PROFILE.FIELDS.DOMAIN')"
:placeholder="t('COMPANIES.DETAIL.EDIT.PLACEHOLDERS.DOMAIN')"
:disabled="isUpdating"
/>
</div>
<div class="flex flex-col gap-1">
<label class="text-label text-n-slate-12">
{{ t('COMPANIES.DETAIL.EDIT.DESCRIPTION') }}
</label>
<TextArea
v-model="form.description"
:placeholder="t('COMPANIES.DETAIL.PROFILE.DESCRIPTION_PLACEHOLDER')"
:disabled="isUpdating"
:max-length="DESCRIPTION_MAX_LENGTH"
class="w-full"
show-character-count
auto-height
/>
</div>
</section>
<section class="flex flex-col gap-4">
<h4 class="mb-0 text-heading-2 text-n-slate-12">
{{ t('COMPANIES.DETAIL.EDIT.DETAILS') }}
</h4>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Input
v-model="form.industry"
:label="t('COMPANIES.DETAIL.FACTS.INDUSTRY')"
:placeholder="t('COMPANIES.DETAIL.EDIT.PLACEHOLDERS.INDUSTRY')"
:disabled="isUpdating"
/>
<Input
v-model="form.employees"
:label="t('COMPANIES.DETAIL.FACTS.SIZE')"
:placeholder="t('COMPANIES.DETAIL.EDIT.PLACEHOLDERS.EMPLOYEES')"
:disabled="isUpdating"
/>
<Input
v-model="form.city"
:label="t('COMPANIES.DETAIL.EDIT.CITY')"
:disabled="isUpdating"
/>
<Input
v-model="form.country"
:label="t('COMPANIES.DETAIL.EDIT.COUNTRY')"
:disabled="isUpdating"
/>
<Input
v-model="form.phone"
:label="t('COMPANIES.DETAIL.EDIT.PHONE')"
type="tel"
:disabled="isUpdating"
/>
</div>
</section>
<section class="flex flex-col gap-4">
<h4 class="mb-0 text-heading-2 text-n-slate-12">
{{ t('COMPANIES.DETAIL.EDIT.SOCIAL.TITLE') }}
</h4>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<SocialProfileInput
v-for="network in SOCIAL_NETWORKS"
:key="network"
v-model="form.socialProfiles[network]"
:network="network"
:label="socialLabels[network]"
:placeholder="t('COMPANIES.DETAIL.EDIT.PLACEHOLDERS.USERNAME')"
:disabled="isUpdating"
/>
</div>
</section>
<section class="flex flex-col gap-4">
<h4 class="mb-0 text-heading-2 text-n-slate-12">
{{ t('COMPANIES.DETAIL.ATTRIBUTES.TITLE') }}
</h4>
<CompanyCustomAttributes :company="company" />
</section>
</div>
<template #footer>
<div class="flex items-center justify-end w-full gap-3">
<Button
:label="t('DIALOG.BUTTONS.CANCEL')"
variant="faded"
color="slate"
type="button"
@click="close"
/>
<Button
:label="t('COMPANIES.DETAIL.EDIT.SAVE')"
color="blue"
type="button"
:disabled="isFormInvalid || isUpdating"
:is-loading="isUpdating"
@click="handleConfirm"
/>
</div>
</template>
</SidePanel>
</template>

View File

@@ -0,0 +1,119 @@
<script setup>
import { computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { dateFormat } from 'shared/helpers/timeHelper';
import { getCompanyProfile } from 'dashboard/components-next/Companies/helpers/companyHelper';
import Flag from 'dashboard/components-next/flag/Flag.vue';
const props = defineProps({
company: { type: Object, required: true },
});
// Padded to full rows so the gap-based hairlines never show an empty grey cell.
const COLUMNS = 4;
const { t } = useI18n();
const store = useStore();
const companyAttributes = useMapGetter('attributes/getCompanyAttributes');
onMounted(() => store.dispatch('attributes/get'));
const formatCustomValue = (attribute, value) => {
if (attribute.attributeDisplayType === 'checkbox') {
return value
? t('COMPANIES.DETAIL.FACTS.YES')
: t('COMPANIES.DETAIL.FACTS.NO');
}
if (attribute.attributeDisplayType === 'date')
return dateFormat(new Date(value).getTime() / 1000);
return String(value);
};
const customFacts = computed(() => {
const values = props.company.customAttributes || {};
return (companyAttributes.value || [])
.filter(
({ attributeKey }) =>
![undefined, null, ''].includes(values[attributeKey])
)
.map(attribute => ({
key: `custom-${attribute.attributeKey}`,
icon: 'i-lucide-tag',
label: attribute.attributeDisplayName,
value: formatCustomValue(attribute, values[attribute.attributeKey]),
}));
});
const facts = computed(() => {
const { industry, employees, location, fullLocation, countryCode } =
getCompanyProfile(props.company.additionalAttributes);
return [
{
key: 'industry',
icon: 'i-lucide-building-2',
label: t('COMPANIES.DETAIL.FACTS.INDUSTRY'),
value: industry,
},
{
key: 'size',
icon: 'i-lucide-users',
label: t('COMPANIES.DETAIL.FACTS.SIZE'),
value: employees,
},
{
key: 'location',
icon: 'i-lucide-map-pin',
label: t('COMPANIES.DETAIL.FACTS.LOCATION'),
value: location,
title: fullLocation,
countryCode,
},
{
key: 'added',
icon: 'i-lucide-calendar',
label: t('COMPANIES.DETAIL.FACTS.ADDED'),
value: props.company.createdAt && dateFormat(props.company.createdAt),
},
...customFacts.value,
];
});
const emptyCells = computed(
() => (COLUMNS - (facts.value.length % COLUMNS)) % COLUMNS
);
</script>
<template>
<div class="overflow-hidden border rounded-xl border-n-weak bg-n-weak">
<dl class="grid grid-cols-2 gap-px m-0 md:grid-cols-4">
<div
v-for="fact in facts"
:key="fact.key"
class="flex flex-col min-w-0 gap-1 px-4 py-3 bg-n-solid-2"
>
<dt class="flex items-center gap-1.5 text-label-small text-n-slate-10">
<span :class="fact.icon" class="size-3.5 shrink-0" />
{{ fact.label }}
</dt>
<dd
class="flex items-start gap-1.5 text-heading-3"
:class="fact.value ? 'text-n-slate-12' : 'text-n-slate-9'"
>
<Flag
v-if="fact.value && fact.countryCode"
:country="fact.countryCode"
class="mt-0.5 shrink-0"
/>
<span class="line-clamp-2" :title="fact.title">
{{ fact.value || t('COMPANIES.DETAIL.FACTS.UNKNOWN') }}
</span>
</dd>
</div>
<div v-for="n in emptyCells" :key="`empty-${n}`" class="bg-n-solid-2" />
</dl>
</div>
</template>

View File

@@ -1,67 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import ConversationCard from 'dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
defineProps({
conversations: {
type: Array,
default: () => [],
},
isLoading: {
type: Boolean,
default: false,
},
});
const { t } = useI18n();
const contactsById = useMapGetter('contacts/getContactById');
const stateInbox = useMapGetter('inboxes/getInboxById');
const accountLabels = useMapGetter('labels/getLabels');
const accountLabelsValue = computed(() => accountLabels.value);
const conversationContact = conversation => {
const sender = conversation.meta?.sender || {};
const contact = contactsById.value(sender.id);
return contact.id ? contact : sender;
};
const conversationInbox = conversation =>
stateInbox.value(conversation.inboxId) || {
name: '',
channelType: conversation.meta?.channel,
};
</script>
<template>
<div
v-if="isLoading"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<div
v-else-if="conversations.length > 0"
class="px-6 divide-y divide-n-strong [&>*:hover]:!border-y-transparent [&>*:hover+*]:!border-t-transparent"
>
<ConversationCard
v-for="conversation in conversations"
:key="conversation.id"
:conversation="conversation"
:contact="conversationContact(conversation)"
:state-inbox="conversationInbox(conversation)"
:account-labels="accountLabelsValue"
class="rounded-none hover:rounded-xl hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3"
/>
</div>
<p
v-else
class="py-8 px-4 mx-6 text-sm text-center rounded-xl border border-dashed border-n-strong text-n-slate-11"
>
{{ t('COMPANIES.DETAIL.HISTORY.EMPTY') }}
</p>
</template>

View File

@@ -0,0 +1,164 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import { dateFormat } from 'shared/helpers/timeHelper';
import { groupByDay } from 'dashboard/components-next/Companies/helpers/companyHelper';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const props = defineProps({
notes: { type: Array, default: () => [] },
isLoading: { type: Boolean, default: false },
emptyMessage: { type: String, required: true },
hasMore: { type: Boolean, default: false },
highlight: { type: String, default: '' },
});
const emit = defineEmits(['loadMore']);
const NOTE_CLAMP_LENGTH = 240;
const SYSTEM_AUTHOR_ICON = 'i-lucide-settings';
const { t } = useI18n();
const router = useRouter();
const { accountId } = useAccount();
const { formatMessage } = useMessageFormatter();
const currentUser = useMapGetter('getCurrentUser');
const expandedNotes = ref(new Set());
const toggleNote = noteId => {
const next = new Set(expandedNotes.value);
if (next.has(noteId)) next.delete(noteId);
else next.add(noteId);
expandedNotes.value = next;
};
const escapeRegExp = value => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Wrap search matches in <mark>, touching only text between tags so markup stays intact.
const noteHtml = note => {
const html = formatMessage(note.content || '');
const term = props.highlight.trim();
if (!term) return html;
const pattern = new RegExp(`(${escapeRegExp(term)})`, 'gi');
return html
.split(/(<[^>]+>)/)
.map(part =>
part.startsWith('<') ? part : part.replace(pattern, '<mark>$1</mark>')
)
.join('');
};
const noteAuthor = note =>
note.user?.id === currentUser.value.id
? t('CONTACTS_LAYOUT.SIDEBAR.NOTES.YOU')
: note.user?.name || t('COMPANIES.DETAIL.ACTIVITY.SYSTEM_AUTHOR');
const noteContactName = note =>
note.contact?.name || t('COMPANIES.DETAIL.CONTACTS.UNNAMED_CONTACT');
const openContact = contactId => {
router.push({
name: 'contacts_edit',
params: { accountId: accountId.value, contactId },
});
};
const groups = computed(() => groupByDay(props.notes, 'createdAt', t));
</script>
<template>
<section class="flex flex-col gap-5">
<div v-if="isLoading && !notes.length" class="flex justify-center py-12">
<Spinner />
</div>
<p
v-else-if="!notes.length"
class="px-6 py-10 text-sm text-center border border-dashed rounded-xl border-n-weak text-n-slate-11"
>
{{ emptyMessage }}
</p>
<div v-else class="flex flex-col gap-6">
<div v-for="group in groups" :key="group.label" class="flex flex-col">
<h3 class="text-label-small text-n-slate-10">{{ group.label }}</h3>
<ol class="m-0 list-none divide-y ps-0 divide-n-weak">
<li v-for="note in group.items" :key="note.id">
<div class="flex items-start gap-4 py-4">
<Avatar
:name="noteAuthor(note)"
:src="note.user?.thumbnail"
:icon-name="note.user ? null : SYSTEM_AUTHOR_ICON"
:size="40"
hide-offline-status
class="shrink-0"
/>
<span class="flex flex-col flex-1 min-w-0 gap-1">
<span class="truncate text-heading-3 text-n-slate-12">
{{ noteAuthor(note) }}
</span>
<span
v-dompurify-html="noteHtml(note)"
class="text-n-slate-11 text-body-main [&_p]:m-0 [&_p+p]:mt-2 [&_mark]:bg-n-amber-4 [&_mark]:text-n-slate-12 [&_mark]:rounded-sm [&_mark]:px-0.5"
:class="{
'line-clamp-3': !highlight && !expandedNotes.has(note.id),
}"
/>
<button
v-if="
!highlight &&
(note.content || '').length > NOTE_CLAMP_LENGTH
"
type="button"
class="self-start p-0 text-label-small text-n-slate-11 hover:text-n-slate-12"
@click="toggleNote(note.id)"
>
{{
expandedNotes.has(note.id)
? t('COMPANIES.DETAIL.HEADER.LESS')
: t('COMPANIES.DETAIL.HEADER.MORE')
}}
</button>
<span class="flex items-center gap-1.5 text-xs text-n-slate-10">
<span class="leading-4 tabular-nums">
{{ dateFormat(note.createdAt, 'h:mm a') }}
</span>
<template v-if="note.contact">
<span class="rounded-full size-1 bg-n-slate-8" />
<button
type="button"
class="p-0 text-xs leading-4 truncate text-n-slate-10 hover:text-n-slate-12 hover:underline"
@click="openContact(note.contact.id)"
>
{{ noteContactName(note) }}
</button>
</template>
</span>
</span>
</div>
</li>
</ol>
</div>
</div>
<Button
v-if="hasMore && notes.length"
:label="t('COMPANIES.DETAIL.ACTIVITY.LOAD_MORE')"
variant="faded"
color="slate"
size="sm"
class="self-center"
:is-loading="isLoading"
@click="emit('loadMore')"
/>
</section>
</template>

View File

@@ -1,123 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { useExactTimestamp } from 'shared/composables/useExactTimestamp';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const props = defineProps({
notes: {
type: Array,
default: () => [],
},
isLoading: {
type: Boolean,
default: false,
},
});
const exactTimestamp = useExactTimestamp();
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const { formatMessage } = useMessageFormatter();
const currentUser = useMapGetter('getCurrentUser');
const hasNotes = computed(() => props.notes.length > 0);
const contactName = contact =>
contact?.name || t('COMPANIES.DETAIL.CONTACTS.UNNAMED_CONTACT');
const getWrittenBy = note => {
const isCurrentUser = note?.user?.id === currentUser.value.id;
return isCurrentUser
? t('CONTACTS_LAYOUT.SIDEBAR.NOTES.YOU')
: note?.user?.name || 'Bot';
};
const openContact = contactId => {
router.push({
name: 'contacts_edit',
params: {
accountId: route.params.accountId,
contactId,
},
});
};
</script>
<template>
<div v-if="hasNotes" class="flex flex-col px-6">
<div class="flex flex-col divide-y divide-n-strong">
<div
v-for="note in notes"
:key="note.id"
class="flex flex-col gap-2 py-4 group/note"
>
<div class="flex items-center gap-1.5 min-w-0">
<Avatar
:name="contactName(note.contact)"
:src="note.contact?.thumbnail"
:size="16"
rounded-full
hide-offline-status
/>
<div
class="flex items-center justify-between min-w-0 gap-1 w-full text-sm text-n-slate-11"
>
<button
type="button"
class="min-w-0 font-medium truncate text-start text-n-slate-12 hover:text-n-blue-11 p-0"
@click="openContact(note.contact.id)"
>
{{ contactName(note.contact) }}
</button>
<div class="min-w-0 truncate">
<span
class="inline-flex items-center gap-1 text-sm text-n-slate-10"
>
<span class="font-medium text-n-slate-11">
{{ getWrittenBy(note) }}
</span>
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.WROTE') }}
<span
v-tooltip.top="{
content: exactTimestamp(note.createdAt),
delay: { show: 500, hide: 0 },
}"
class="font-medium text-n-slate-11"
>
{{ dynamicTime(note.createdAt) }}
</span>
</span>
</div>
</div>
</div>
<p
v-dompurify-html="formatMessage(note.content || '')"
class="mb-0 prose-sm prose-p:text-sm prose-p:leading-relaxed prose-p:mb-1 prose-p:mt-0 prose-ul:mb-1 prose-ul:mt-0 text-n-slate-12"
/>
</div>
</div>
</div>
<div
v-else-if="isLoading"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<p
v-else
class="py-8 mx-6 px-4 text-sm text-center rounded-xl border border-dashed border-n-strong text-n-slate-11"
>
{{ t('COMPANIES.DETAIL.NOTES.EMPTY') }}
</p>
</template>

View File

@@ -0,0 +1,181 @@
<script setup>
import { computed, onMounted, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useDebounceFn } from '@vueuse/core';
import { useAlert } from 'dashboard/composables';
import { useAccount } from 'dashboard/composables/useAccount';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Flag from 'dashboard/components-next/flag/Flag.vue';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import { useCompaniesStore } from 'dashboard/stores/companies';
const props = defineProps({
company: { type: Object, required: true },
query: { type: String, default: '' },
});
const SEARCH_DEBOUNCE_MS = 300;
// Matches Api::V1::Accounts::Companies::ContactsController::RESULTS_PER_PAGE
const PAGE_SIZE = 25;
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const { accountId } = useAccount();
const companiesStore = useCompaniesStore();
const contacts = computed(() => companiesStore.companyContacts);
const meta = computed(() => companiesStore.companyContactsMeta);
const isLoading = computed(() => companiesStore.getUIFlags.fetchingContacts);
const isRemoving = computed(() => companiesStore.getUIFlags.removingContact);
const page = computed(() => Number(meta.value.page || 1));
const matchCount = computed(() => Number(meta.value.totalCount || 0));
const fetchPage = targetPage => {
const search = props.query.trim() || undefined;
router.replace({
query: {
...route.query,
page: targetPage > 1 ? String(targetPage) : undefined,
q: search,
},
});
return companiesStore.getCompanyContacts(
props.company.id,
targetPage,
search
);
};
onMounted(() => fetchPage(Number(route.query.page) || 1));
const debouncedSearch = useDebounceFn(() => fetchPage(1), SEARCH_DEBOUNCE_MS);
watch(() => props.query, debouncedSearch);
const contactName = contact =>
contact.name ||
contact.email ||
t('COMPANIES.DETAIL.CONTACTS.UNNAMED_CONTACT');
const contactMeta = contact => {
const { city, country, countryCode } = contact.additionalAttributes || {};
const location = [city, country].filter(Boolean).join(', ');
return [
contact.email && { key: 'email', label: contact.email },
contact.phoneNumber && { key: 'phone', label: contact.phoneNumber },
location && { key: 'location', label: location, countryCode },
].filter(Boolean);
};
const openContact = contactId => {
router.push({
name: 'contacts_edit',
params: { accountId: accountId.value, contactId },
});
};
const removeContact = async contact => {
const targetPage =
page.value > 1 && contacts.value.length === 1 ? page.value - 1 : page.value;
try {
await companiesStore.removeContactFromCompany(
props.company.id,
contact.id,
targetPage
);
if (props.query.trim()) await fetchPage(targetPage);
useAlert(t('COMPANIES.DETAIL.CONTACTS.MESSAGES.REMOVE_SUCCESS'));
} catch {
useAlert(t('COMPANIES.DETAIL.CONTACTS.MESSAGES.REMOVE_ERROR'));
}
};
</script>
<template>
<section class="flex flex-col gap-4">
<div
v-if="isLoading && !contacts.length"
class="flex justify-center py-12 text-n-slate-11"
>
<Spinner />
</div>
<div v-else-if="contacts.length" class="divide-y divide-n-weak">
<div
v-for="contact in contacts"
:key="contact.id"
class="flex items-start justify-between gap-4 py-4"
>
<div class="flex items-start flex-1 min-w-0 gap-4">
<Avatar
:name="contactName(contact)"
:src="contact.thumbnail"
:size="40"
hide-offline-status
/>
<div class="flex flex-col flex-1 min-w-0 gap-1">
<button
type="button"
class="block p-0 truncate text-heading-3 text-start text-n-slate-12 hover:text-n-blue-11"
@click="openContact(contact.id)"
>
{{ contactName(contact) }}
</button>
<div v-if="contactMeta(contact).length" class="overflow-hidden">
<div class="flex flex-wrap gap-y-1 -ms-[calc(0.75rem+1px)]">
<span
v-for="item in contactMeta(contact)"
:key="item.key"
class="inline-flex items-center min-w-0 gap-1.5 text-body-main text-n-slate-11 max-w-full me-3 before:content-[''] before:w-px before:h-3 before:shrink-0 before:bg-n-slate-6 before:me-1.5"
:title="item.label"
>
<Flag
v-if="item.countryCode"
:country="item.countryCode"
class="size-3.5 shrink-0"
/>
<span class="truncate">{{ item.label }}</span>
</span>
</div>
</div>
</div>
</div>
<Button
v-tooltip.top="t('COMPANIES.DETAIL.CONTACTS.ACTIONS.REMOVE')"
icon="i-lucide-unlink"
slate
sm
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
:disabled="isRemoving"
@click="removeContact(contact)"
/>
</div>
</div>
<p
v-else
class="px-4 py-10 text-sm text-center border border-dashed rounded-xl border-n-weak text-n-slate-11"
>
{{
query.trim()
? t('COMPANIES.DETAIL.PEOPLE.NO_MATCHES')
: t('COMPANIES.DETAIL.PEOPLE.EMPTY')
}}
</p>
<PaginationFooter
v-if="matchCount > PAGE_SIZE"
current-page-info="CONTACTS_LAYOUT.PAGINATION_FOOTER.SHOWING"
:current-page="page"
:total-items="matchCount"
:items-per-page="PAGE_SIZE"
class="!px-0 before:hidden bg-transparent"
@update:current-page="fetchPage"
/>
</section>
</template>

View File

@@ -1,221 +0,0 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { useExactTimestamp } from 'shared/composables/useExactTimestamp';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import { useCompaniesStore } from 'dashboard/stores/companies';
const props = defineProps({
company: { type: Object, default: () => ({}) },
isLoading: { type: Boolean, default: false },
});
const exactTimestamp = useExactTimestamp();
const { t } = useI18n();
const companiesStore = useCompaniesStore();
const form = reactive({ name: '', domain: '', description: '' });
const avatarPreviewUrl = ref('');
const isUploadingAvatar = ref(false);
const uiFlags = computed(() => companiesStore.getUIFlags);
const isUpdating = computed(() => uiFlags.value.updatingItem);
const isAvatarBusy = computed(
() =>
isUploadingAvatar.value || uiFlags.value.deletingAvatar || isUpdating.value
);
const displayName = computed(
() => props.company?.name || t('COMPANIES.UNNAMED')
);
const avatarSource = computed(
() => avatarPreviewUrl.value || props.company?.avatarUrl || ''
);
const isFormInvalid = computed(() => !form.name.trim());
const hasChanges = computed(
() =>
form.name.trim() !== (props.company?.name || '').trim() ||
form.domain.trim() !== (props.company?.domain || '').trim() ||
form.description.trim() !== (props.company?.description || '').trim()
);
const summary = computed(() => {
const { createdAt, lastActivityAt } = props.company || {};
return [
createdAt && {
key: 'createdAt',
label: t('COMPANIES.DETAIL.PROFILE.CREATED_AT', {
date: dynamicTime(createdAt),
}),
exactTime: exactTimestamp(createdAt),
},
lastActivityAt && {
key: 'lastActivityAt',
label: t('COMPANIES.DETAIL.PROFILE.LAST_ACTIVE', {
date: dynamicTime(lastActivityAt),
}),
exactTime: exactTimestamp(lastActivityAt),
},
].filter(Boolean);
});
const syncForm = company => {
form.name = company?.name || '';
form.domain = company?.domain || '';
form.description = company?.description || '';
};
const isCurrentCompany = companyId => Number(props.company?.id) === companyId;
watch(
() => [
props.company?.id,
props.company?.name,
props.company?.domain,
props.company?.description,
props.company?.avatarUrl,
],
() => {
avatarPreviewUrl.value = '';
syncForm(props.company);
},
{ immediate: true }
);
const handleAvatarUpload = async ({ file, url }) => {
avatarPreviewUrl.value = url;
isUploadingAvatar.value = true;
try {
await companiesStore.update({ id: props.company.id, avatar: file });
useAlert(t('COMPANIES.DETAIL.AVATAR.UPLOAD_SUCCESS'));
} catch {
avatarPreviewUrl.value = '';
useAlert(t('COMPANIES.DETAIL.AVATAR.UPLOAD_ERROR'));
} finally {
isUploadingAvatar.value = false;
}
};
const handleAvatarDelete = async () => {
try {
await companiesStore.deleteCompanyAvatar(props.company.id);
avatarPreviewUrl.value = '';
useAlert(t('COMPANIES.DETAIL.AVATAR.DELETE_SUCCESS'));
} catch {
useAlert(t('COMPANIES.DETAIL.AVATAR.DELETE_ERROR'));
}
};
const handleUpdateCompany = async () => {
const companyId = Number(props.company.id);
try {
const updated = await companiesStore.update({
id: companyId,
name: form.name.trim(),
domain: form.domain.trim() || null,
description: form.description.trim() || null,
});
if (!isCurrentCompany(companyId)) return;
syncForm(updated);
useAlert(t('COMPANIES.DETAIL.PROFILE.MESSAGES.UPDATE_SUCCESS'));
} catch {
if (!isCurrentCompany(companyId)) return;
syncForm(props.company);
useAlert(t('COMPANIES.DETAIL.PROFILE.MESSAGES.UPDATE_ERROR'));
}
};
</script>
<template>
<div v-if="isLoading && !company?.id" class="text-sm text-n-slate-11">
{{ t('COMPANIES.DETAIL.LOADING') }}
</div>
<div v-else-if="company?.id" class="flex flex-col items-start gap-8 pb-6">
<div class="flex flex-col items-start gap-3">
<Avatar
:name="displayName"
:src="avatarSource"
:size="72"
:allow-upload="!isAvatarBusy"
hide-offline-status
@upload="handleAvatarUpload"
@delete="handleAvatarDelete"
/>
<div class="flex flex-col gap-1">
<h3 class="text-base font-medium text-n-slate-12">
{{ displayName }}
</h3>
<span class="text-sm leading-6 text-n-slate-11">
<template v-for="(item, index) in summary" :key="item.key">
<span v-if="index">&nbsp;•&nbsp;</span>
<span
v-tooltip.top="{
content: item.exactTime,
delay: { show: 500, hide: 0 },
}"
>
{{ item.label }}
</span>
</template>
</span>
<p
v-if="isUploadingAvatar || uiFlags.deletingAvatar"
class="text-sm text-n-slate-11"
>
{{ t('COMPANIES.DETAIL.AVATAR.UPDATING') }}
</p>
</div>
</div>
<div class="flex flex-col items-start w-full gap-6">
<span class="py-1 text-sm font-medium text-n-slate-12">
{{ t('COMPANIES.DETAIL.PROFILE.TITLE') }}
</span>
<div class="grid w-full gap-4 sm:grid-cols-2">
<Input
v-model="form.name"
:placeholder="t('COMPANIES.DETAIL.PROFILE.FIELDS.NAME')"
:disabled="isUpdating"
custom-input-class="h-8 !pt-1 !pb-1"
/>
<Input
v-model="form.domain"
:placeholder="t('COMPANIES.DETAIL.PROFILE.FIELDS.DOMAIN')"
:disabled="isUpdating"
custom-input-class="h-8 !pt-1 !pb-1"
/>
</div>
<TextArea
v-model="form.description"
:placeholder="t('COMPANIES.DETAIL.PROFILE.DESCRIPTION_PLACEHOLDER')"
:disabled="isUpdating"
:max-length="280"
class="w-full"
show-character-count
auto-height
/>
<Button
:label="t('COMPANIES.DETAIL.PROFILE.ACTIONS.SAVE')"
size="sm"
:is-loading="isUpdating"
:disabled="isUpdating || isFormInvalid || !hasChanges"
@click="handleUpdateCompany"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,30 @@
<script setup>
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Input from 'dashboard/components-next/input/Input.vue';
defineProps({
placeholder: { type: String, required: true },
});
const query = defineModel({ type: String, default: '' });
</script>
<template>
<Input
v-model="query"
type="search"
:placeholder="placeholder"
size="sm"
:custom-input-class="[
'[&:not(.focus)]:!border-transparent bg-n-alpha-2 dark:bg-n-solid-1 !ps-8',
]"
class="w-full sm:w-56"
>
<template #prefix>
<Icon
icon="i-lucide-search"
class="absolute -translate-y-1/2 text-n-slate-11 size-4 top-1/2 start-2"
/>
</template>
</Input>
</template>

View File

@@ -0,0 +1,43 @@
import { fromUnixTime, isToday, isYesterday } from 'date-fns';
import { dateFormat } from 'shared/helpers/timeHelper';
export const getCompanyProfile = additionalAttributes => {
const {
industry,
sub_industry: subIndustry,
employee_count_range: employeeCountRange,
employee_count: employeeCount,
city,
country,
country_code: countryCode,
} = additionalAttributes || {};
return {
industry: subIndustry || industry,
employees: employeeCount
? employeeCount.toLocaleString()
: employeeCountRange,
location: [city, city ? countryCode || country : country]
.filter(Boolean)
.join(', '),
fullLocation: [city, country].filter(Boolean).join(', '),
countryCode,
};
};
export const groupByDay = (items, timeKey, t) => {
const dayLabel = time => {
const date = fromUnixTime(time);
if (isToday(date)) return t('COMPANIES.DETAIL.ACTIVITY.TODAY');
if (isYesterday(date)) return t('COMPANIES.DETAIL.ACTIVITY.YESTERDAY');
return dateFormat(time, 'EEEE, MMM d');
};
return items.reduce((groups, item) => {
const label = dayLabel(item[timeKey]);
const group = groups.at(-1);
if (group?.label === label) group.items.push(item);
else groups.push({ label, items: [item] });
return groups;
}, []);
};

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