Compare commits

..
Author SHA1 Message Date
Muhsin KelothandGitHub 136c2d8e58 Merge branch 'develop' into feat/email-forwarding 2025-09-22 16:48:42 +05:30
b5deecc9f9 feat: Accept file attachment in line channel (#12321)
# Pull Request Template

## Description
This pull request allow LINE to receive files. 

## Type of change

Please delete options that are not relevant.

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update

## How Has This Been Tested?
add testcase. and test manually by myself.
in case you want to test in android, use native share method to share
files to LINE.
you can share more file types to LINE (native line share only send
image,video and audio).


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

---------

Co-authored-by: mix5003 <mix5003@debian.debian>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-09-22 15:06:28 +05:30
be44108500 chore: Add missing space in signup link (#12475)
## Description

This branch adds a missing space in the signup footer, **changing**:
`Already have an account?Login to Chatwoot` **to** `Already have an
account? Login to Chatwoot`

## Type of change
Non-breaking change

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-09-22 13:23:28 +05:30
891404aaf1 feat: Captain animating SVGs (#12448)
# Pull Request Template

## Description

This PR includes new animating SVG for Captain pages

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


###Screencast


https://github.com/user-attachments/assets/181470d4-2ac7-4056-83bb-7371ba214b6f




## 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
- [ ] 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: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-09-22 10:10:26 +05:30
98f4a6f797 chore: Ensure admin notification mailer specs are order agnostic (#12472)
## Summary
- update the admin notification base mailer spec to ignore ordering when
verifying administrator email addresses
- extend the channel and integrations admin notification mailer specs to
cover multiple administrators without relying on recipient order

------
https://chatgpt.com/codex/tasks/task_e_68cc7457cf788326a765f116ceab1732

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-09-19 16:37:57 +05:30
Sojan Jose d06aba6020 Merge branch 'release/4.6.0' into develop 2025-09-19 13:44:38 +05:30
Sojan Jose 26a7e1b626 Bump version to 4.6.0 2025-09-19 13:43:21 +05:30
d9af219ba3 feat: Implement single audio playback functionality across components (#12226)
## Description

Introduces a global single-audio playback helper and hooks it into
dashboard and widget entrypoints. Adds play/pause event handlers in the
Audio chip to sync UI state. The helper enforces one audio playing at a
time and auto-advances to the next adjacent audio on end.

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
2025-09-19 12:52:01 +05:30
PranavandGitHub e3020fbe2c fix: Use case sensitive filter for phone_numbers (#12470)
The contact filter APIs were timing out due to the case‑insensitive
filter. There is no index for lower case phone numbers, so it would
perform a table scan, potentially examining 8 million records or more at
a time.

This change should fix the issue. 
I am changing the filter to use direct comparison without lower‑case.

**Previous:**
```sql
SELECT contacts.*
FROM contacts
WHERE contacts.account_id = $1
  AND (
    LOWER(contacts.phone_number) = '<number>'
    OR LOWER(contacts.phone_number) = '<other-number>'
  )
ORDER BY contacts.created_at DESC NULLS LAST
LIMIT $2
OFFSET $3
```

**Updated:**
```sql
SELECT contacts.*
FROM contacts
WHERE contacts.account_id = $1
  AND (
    contacts.phone_number = '<number>'
    OR contacts.phone_number = '<other-number>'
  )
ORDER BY contacts.created_at DESC NULLS LAST
LIMIT $2
OFFSET $3
```

Fixes:
https://linear.app/chatwoot/issue/CW-5582/contact-filter-timing-out
2025-09-19 12:39:17 +05:30
4014a846f0 feat: Add the frontend support for MFA (#12372)
FE support for https://github.com/chatwoot/chatwoot/pull/12290
## Linear:
- https://github.com/chatwoot/chatwoot/issues/486

## Description
This PR implements Multi-Factor Authentication (MFA) support for user
accounts, enhancing security by requiring a second form of verification
during login. The feature adds TOTP (Time-based One-Time Password)
authentication with QR code generation and backup codes for account
recovery.

## Type of change

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

## How Has This Been Tested?

- Added comprehensive RSpec tests for MFA controller functionality
- Tested MFA setup flow with QR code generation
- Verified OTP validation and backup code generation
- Tested login flow with MFA enabled/disabled

## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] 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
- [ ] 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

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2025-09-18 21:16:06 +05:30
239c4dcb91 feat: MFA (#12290)
## Linear:
- https://github.com/chatwoot/chatwoot/issues/486

## Description
This PR implements Multi-Factor Authentication (MFA) support for user
accounts, enhancing security by requiring a second form of verification
during login. The feature adds TOTP (Time-based One-Time Password)
authentication with QR code generation and backup codes for account
recovery.

## Type of change

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

## How Has This Been Tested?

- Added comprehensive RSpec tests for MFA controller functionality
- Tested MFA setup flow with QR code generation
- Verified OTP validation and backup code generation
- Tested login flow with MFA enabled/disabled

## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] 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
- [ ] 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

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-09-18 20:19:24 +05:30
f03a52bd77 feat: Add media_name support for WhatsApp templates document files (#12462)
## Description

This implementation adds support for the `media_name` parameter for
WhatsApp document templates, resolving the issue where documents appear
as "untitled" when sent via templates.

**Problem solved:** Documents sent via WhatsApp templates always
appeared as "untitled" because Chatwoot didn't process the `filename`
field required by the WhatsApp API.

**Solution:** Added support for the `media_name` parameter that maps to
the WhatsApp API's `filename` field.

## Type of change

- [x] New feature (non-breaking change which adds functionality)
- [x] This change requires a documentation update

## How Has This Been Tested?

Created and executed **7 comprehensive test scenarios**:

1. ✅ Document without `media_name` (backward compatibility)
2. ✅ Document with valid `media_name`
3. ✅ Document with blank `media_name`
4. ✅ Document with null `media_name`
5. ✅ Image with `media_name` (ignored as expected)
6. ✅ Video with `media_name` (ignored as expected)
7. ✅ Blank URL (returns nil appropriately)

**All tests passed** and confirmed **100% backward compatibility**.

## Technical Implementation

**Backend Changes:**
- `PopulateTemplateParametersService`: Added `media_name` parameter
support
- `TemplateProcessorService`: Pass `media_name` to parameter builder
- `WhatsappCloudService`: Updated documentation with `media_name`
example

**Frontend Changes:**
- `WhatsAppTemplateParser.vue`: Added UI field for document filename
input
- `templateHelper.js`: Include `media_name` for document templates
- `whatsappTemplates.json`: Added translation key for document name
placeholder

**Key Features:**
- 🔄 **100% Backward Compatible** - Existing templates continue working
- 📝 **Document Filename Support** - Users can specify custom filenames
- 🎯 **Document-Only Feature** - Only affects document media types
- ✅ **Comprehensive Testing** - All edge cases covered

## Expected Behavior

**Before:**
```ruby
# All documents appear as "untitled"
{
  type: 'document',
  document: { link: 'https://example.com/document.pdf' }
}
```

**After:**
```ruby
# With media_name - displays custom filename
{
  type: 'document',
  document: {
    link: 'https://example.com/document.pdf',
    filename: 'Invoice_2025.pdf'
  }
}

# Without media_name - works as before
{
  type: 'document',
  document: { link: 'https://example.com/document.pdf' }
}
```

## 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
- [x] Any dependent changes have been merged and published in downstream
modules

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-09-18 15:25:31 +05:30
Shivam MishraandGitHub 8f4b252045 feat: allow searching captain responses [CW-5631] (#12463) 2025-09-18 14:44:56 +05:30
9527ff6269 feat: Add support for labels in automations (#11658)
- Add support for using labels as an action event for automation
 - Fix duplicated conversation_updated event dispatch for labels
 

Fixes https://github.com/chatwoot/chatwoot/issues/8539 and multiple
issues around duplication related to label change events.
---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-09-18 14:17:54 +05:30
iamsivin 38b1d8fbfa Merge branch 'develop' into feat/email-forwarding 2025-09-17 10:19:06 +05:30
Sivin VargheseandGitHub 2736dc2f90 Merge branch 'develop' into feat/email-forwarding 2025-06-12 15:46:58 +05:30
Sivin VargheseandGitHub d3295b3838 Merge branch 'develop' into feat/email-forwarding 2025-06-04 22:24:17 +05:30
Sivin VargheseandGitHub c464b50854 Merge branch 'develop' into feat/email-forwarding 2025-05-29 15:33:19 +05:30
Sivin VargheseandGitHub 1d174c085d Merge branch 'develop' into feat/email-forwarding 2025-05-29 13:04:35 +05:30
Sivin VargheseandGitHub 690000fb97 Merge branch 'develop' into feat/email-forwarding 2025-05-28 14:47:24 +05:30
Sivin VargheseandGitHub c99d846348 Merge branch 'develop' into feat/email-forwarding 2025-05-28 09:34:00 +05:30
iamsivin 2ff71c21e6 chore: Fix build 2025-05-26 12:50:26 +05:30
Sivin VargheseandGitHub 0862277564 Merge branch 'develop' into feat/email-forwarding 2025-05-23 21:34:24 +05:30
Sivin VargheseandGitHub a9388fe918 Merge branch 'develop' into feat/email-forwarding 2025-05-23 14:02:44 +05:30
iamsivin 2e6998e57a chore: Minor fix 2025-05-23 14:02:13 +05:30
iamsivin d3e3809118 chore: MInor fix 2025-05-23 12:51:40 +05:30
Sivin VargheseandGitHub e973309610 Merge branch 'develop' into feat/email-forwarding 2025-05-23 12:12:31 +05:30
Sivin VargheseandGitHub 81d2c018cd Merge branch 'develop' into feat/email-forwarding 2025-05-21 23:28:22 +05:30
Sivin VargheseandGitHub 7f3db1fe29 Merge branch 'develop' into feat/email-forwarding 2025-05-20 23:45:15 +05:30
Sivin VargheseandGitHub a4d749b9b9 Merge branch 'develop' into feat/email-forwarding 2025-05-18 18:57:32 +05:30
Sivin VargheseandGitHub 4deb4b04af Merge branch 'develop' into feat/email-forwarding 2025-05-16 23:08:16 +05:30
Sivin VargheseandGitHub 5c0f0cf0a8 Merge branch 'develop' into feat/email-forwarding 2025-05-16 22:21:22 +05:30
Sivin VargheseandGitHub 1ce7f1bd64 Merge branch 'develop' into feat/email-forwarding 2025-05-15 15:20:13 +05:30
iamsivin 162929eb7a chore: Minor fix 2025-05-13 17:41:25 +05:30
iamsivin 12ace33254 chore: Clean up 2025-05-13 16:43:45 +05:30
Sivin VargheseandGitHub 32158da454 Merge branch 'develop' into feat/email-forwarding 2025-05-13 15:42:36 +05:30
Sivin VargheseandGitHub 3ad8156ea2 Merge branch 'develop' into feat/email-forwarding 2025-05-12 21:19:04 +05:30
iamsivin aa8cafb9b5 chore: Replace scroll lock composable usage 2025-05-12 20:31:59 +05:30
iamsivin d5267057f6 chore: Minor fix 2025-05-09 18:30:42 +05:30
iamsivin 0a31e6ee63 chore: use CustomTeleport 2025-05-09 17:58:22 +05:30
Sivin VargheseandGitHub 7633602ca5 Merge branch 'develop' into feat/email-forwarding 2025-05-09 17:52:01 +05:30
iamsivin b8ec6d6235 chore: Fix code climate 2025-05-09 17:40:49 +05:30
iamsivin be10e450e3 chore: Minor fix 2025-05-09 17:31:20 +05:30
iamsivin e722bebed8 chore: Minor fix 2025-05-09 17:28:29 +05:30
iamsivin 78ae5ef8fd chore: Lock context menu 2025-05-09 16:53:33 +05:30
iamsivin 0c5379147d chore: Scroll lock composable to reuse 2025-05-09 16:47:53 +05:30
Sivin VargheseandGitHub 9c5af11b84 Merge branch 'develop' into feat/email-forwarding 2025-05-09 08:41:21 +05:30
PranavandGitHub 43c640a76e Merge branch 'develop' into feat/email-forwarding 2025-05-08 19:16:11 -07:00
iamsivin 09e76b21ca chore: Clean up 2025-05-09 00:22:30 +05:30
iamsivin bfec3b066f chore: Fix padding issue 2025-05-09 00:15:25 +05:30
Sivin VargheseandGitHub 9fafd37e3b Merge branch 'develop' into feat/email-forwarding 2025-05-08 22:39:26 +05:30
iamsivin 6def703a60 chore: Minor fix 2025-05-08 17:06:24 +05:30
Sivin VargheseandGitHub 6ca8b3abca Merge branch 'develop' into feat/email-forwarding 2025-05-08 16:52:59 +05:30
iamsivin cffd74fda3 chore: Replace message menu with context menu 2025-05-08 16:52:18 +05:30
Sivin VargheseandGitHub 2856818931 Merge branch 'develop' into feat/email-forwarding 2025-05-08 12:21:12 +05:30
Sivin VargheseandGitHub 3e7df0e76a Merge branch 'develop' into feat/email-forwarding 2025-05-07 21:27:27 +05:30
Sivin VargheseandGitHub d747fbacf8 Merge branch 'develop' into feat/email-forwarding 2025-05-07 14:55:10 +05:30
PranavandGitHub 74c6caa982 Merge branch 'develop' into feat/email-forwarding 2025-05-06 13:44:47 -07:00
Sivin VargheseandGitHub 0203ebb419 Merge branch 'develop' into feat/email-forwarding 2025-05-06 22:43:29 +05:30
Sivin VargheseandGitHub cf09aaadfc Merge branch 'develop' into feat/email-forwarding 2025-05-06 08:33:13 +05:30
iamsivin 421597b1bc chore: Minor fix 2025-05-05 16:25:05 +05:30
Sivin VargheseandGitHub 2db43948c0 Merge branch 'develop' into feat/email-forwarding 2025-05-05 16:09:25 +05:30
iamsivin 7ab4343359 chore: Minor fix 2025-05-02 21:33:20 +05:30
iamsivin 76dbc7540f chore: Fix position issue and lock scroll 2025-05-02 21:21:23 +05:30
iamsivin cc44e7ab88 chore: Preview email attachments 2025-05-02 20:20:14 +05:30
iamsivin c1c488eafa chore: Fix build 2025-05-02 20:13:10 +05:30
Sivin VargheseandGitHub b05312aab1 Merge branch 'develop' into feat/email-forwarding 2025-05-02 14:14:56 +05:30
iamsivin 99d7bd9e7c chore: Code clean up moves to service classes 2025-05-02 14:14:15 +05:30
iamsivin 7aefbba2ca chore: MInor fix 2025-05-02 12:34:33 +05:30
Sivin VargheseandGitHub 7c8f4d26fe Merge branch 'develop' into feat/email-forwarding 2025-05-02 08:07:02 +05:30
iamsivin 7b0aaa2721 chore: Fix code climate 2025-05-01 19:11:25 +05:30
iamsivin 00a9a5cdc2 chore: Minor fix 2025-05-01 19:02:25 +05:30
iamsivin ccd24c40e7 chore: Minor fix 2025-05-01 18:59:35 +05:30
iamsivin 754d0ff515 chore: Minor fix 2025-05-01 18:52:25 +05:30
iamsivin f8d3a2a989 chore: Improve formatting with commonmarker and fix spec 2025-05-01 18:40:25 +05:30
iamsivin d1ac7211ec chore: Forward outgoing email message 2025-05-01 17:12:05 +05:30
Sivin VargheseandGitHub f4ca2529ba Merge branch 'develop' into feat/email-forwarding 2025-05-01 13:13:29 +05:30
iamsivin 1094a3924e chore: Forward plain email UI 2025-04-30 22:57:25 +05:30
iamsivin 8326eae4fe chore: Forward with attachments 2025-04-30 22:29:33 +05:30
iamsivin e96f296414 chore: Send with attachment 2025-04-30 11:05:59 +05:30
Sivin VargheseandGitHub abce44c737 Merge branch 'develop' into feat/email-forwarding 2025-04-30 08:35:28 +05:30
iamsivin ab9d10488c chore: Fix spec 2025-04-29 22:03:27 +05:30
Sivin VargheseandGitHub a4a749e0d5 Merge branch 'develop' into feat/email-forwarding 2025-04-29 18:14:30 +05:30
iamsivin f20ef2bfc7 chore: Adds spec 2025-04-29 15:41:42 +05:30
iamsivin 65b38bb4e4 chore: Adds multipart support on email rendering 2025-04-29 14:35:03 +05:30
Sivin VargheseandGitHub 9ab40adc24 Merge branch 'develop' into feat/email-forwarding 2025-04-29 09:23:11 +05:30
iamsivin 77feacb544 chore: Fix date 2025-04-28 22:19:39 +05:30
iamsivin 32664bb840 chore: Minor fix 2025-04-28 22:15:07 +05:30
iamsivin c710c55cf1 chore: Minor fix 2025-04-28 16:44:37 +05:30
iamsivin 38741f9a15 feat: Email forwarding 2025-04-28 16:40:41 +05:30
123 changed files with 9733 additions and 202 deletions
+7
View File
@@ -6,6 +6,13 @@
# Use `rake secret` to generate this variable
SECRET_KEY_BASE=replace_with_lengthy_secure_hex
# Active Record Encryption keys (required for MFA/2FA functionality)
# Generate these keys by running: rails db:encryption:init
# IMPORTANT: Use different keys for each environment (development, staging, production)
# ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=
# ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=
# ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=
# Replace with the URL you are planning to use for your app
FRONTEND_URL=http://0.0.0.0:3000
# To use a dedicated URL for help center pages
+99
View File
@@ -0,0 +1,99 @@
name: Run MFA Tests
permissions:
contents: read
on:
pull_request:
# If two pushes happen within a short time in the same PR, cancel the run of the oldest push
concurrency:
group: pr-${{ github.workflow }}-${{ github.head_ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-22.04
# Only run if MFA test keys are available
if: github.event_name == 'workflow_dispatch' || (github.repository == 'chatwoot/chatwoot' && github.actor != 'dependabot[bot]')
services:
postgres:
image: pgvector/pgvector:pg15
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ''
POSTGRES_DB: postgres
POSTGRES_HOST_AUTH_METHOD: trust
ports:
- 5432:5432
options: >-
--mount type=tmpfs,destination=/var/lib/postgresql/data
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
options: --entrypoint redis-server
env:
RAILS_ENV: test
POSTGRES_HOST: localhost
# Active Record encryption keys required for MFA - test keys only, not for production use
ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY: 'test_key_a6cde8f7b9c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7'
ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY: 'test_key_b7def9a8c0d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d8'
ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT: 'test_salt_c8efa0b9d1e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d9'
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- name: Create database
run: bundle exec rake db:create
- name: Install pgvector extension
run: |
PGPASSWORD="" psql -h localhost -U postgres -d chatwoot_test -c "CREATE EXTENSION IF NOT EXISTS vector;"
- name: Seed database
run: bundle exec rake db:schema:load
- name: Run MFA-related backend tests
run: |
bundle exec rspec \
spec/services/mfa/token_service_spec.rb \
spec/services/mfa/authentication_service_spec.rb \
spec/requests/api/v1/profile/mfa_controller_spec.rb \
spec/controllers/devise_overrides/sessions_controller_spec.rb \
--profile=10 \
--format documentation
env:
NODE_OPTIONS: --openssl-legacy-provider
- name: Run MFA-related tests in user_spec
run: |
# Run specific MFA-related tests from user_spec
bundle exec rspec spec/models/user_spec.rb \
-e "two factor" \
-e "2FA" \
-e "MFA" \
-e "otp" \
-e "backup code" \
--profile=10 \
--format documentation
env:
NODE_OPTIONS: --openssl-legacy-provider
- name: Upload test logs
uses: actions/upload-artifact@v4
if: failure()
with:
name: mfa-test-logs
path: |
log/test.log
tmp/screenshots/
+2
View File
@@ -78,6 +78,8 @@ gem 'barnes'
gem 'devise', '>= 4.9.4'
gem 'devise-secure_password', git: 'https://github.com/chatwoot/devise-secure_password', branch: 'chatwoot'
gem 'devise_token_auth', '>= 1.2.3'
# two-factor authentication
gem 'devise-two-factor', '>= 5.0.0'
# authorization
gem 'jwt'
gem 'pundit'
+8 -1
View File
@@ -212,6 +212,11 @@ GEM
railties (>= 4.1.0)
responders
warden (~> 1.2.3)
devise-two-factor (6.1.0)
activesupport (>= 7.0, < 8.1)
devise (~> 4.0)
railties (>= 7.0, < 8.1)
rotp (~> 6.0)
devise_token_auth (1.2.5)
bcrypt (~> 3.0)
devise (> 3.5.2, < 5)
@@ -722,7 +727,8 @@ GEM
retriable (3.1.2)
reverse_markdown (2.1.1)
nokogiri
rexml (3.4.1)
rexml (3.4.4)
rotp (6.3.0)
rspec-core (3.13.0)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.2)
@@ -1005,6 +1011,7 @@ DEPENDENCIES
debug (~> 1.8)
devise (>= 4.9.4)
devise-secure_password!
devise-two-factor (>= 5.0.0)
devise_token_auth (>= 1.2.3)
dotenv-rails (>= 3.0.0)
down
+59 -6
View File
@@ -9,16 +9,14 @@ class Messages::MessageBuilder
@user = user
@message_type = params[:message_type] || 'outgoing'
@attachments = params[:attachments]
@automation_rule = content_attributes&.dig(:automation_rule_id)
return unless params.instance_of?(ActionController::Parameters)
@in_reply_to = content_attributes&.dig(:in_reply_to)
@items = content_attributes&.dig(:items)
process_content_attributes
end
def perform
process_forwarded_message if @forwarded_message_id.present?
@message = @conversation.messages.build(message_params)
process_attachments
process_forwarded_attachments if @forwarded_message_id.present?
process_emails
@message.save!
@message
@@ -26,6 +24,44 @@ class Messages::MessageBuilder
private
def process_forwarded_message
builder = Messages::ForwardedMessageBuilderService.new(@forwarded_message_id, { to_emails: @params[:to_emails] })
@forwarded_attributes = builder.perform
@forwarded_message_attachments = builder.forwarded_attachments
# Update content to include forwarded message
original_content = @params[:content_original] || @params[:content]
@params[:content] = builder.formatted_content(@params[:content])
# Update email data
return unless @forwarded_attributes[:content_attributes] && @conversation.inbox&.inbox_type == 'Email'
# Ensure we have valid email data structure to avoid breaking the rendering
@forwarded_attributes[:content_attributes][:email] = builder.forwarded_email_data(original_content)
end
# Process attachments from the forwarded message
def process_forwarded_attachments
return if @forwarded_message_attachments.blank?
@forwarded_message_attachments.each do |source_attachment|
# Create a new attachment for the current message
attachment = @message.attachments.build(
account_id: @message.account_id,
file_type: source_attachment.file_type
)
# Attach the file by directly copying it from the source attachment
next unless source_attachment.file.attached?
attachment.file.attach(
io: StringIO.new(source_attachment.file.download),
filename: source_attachment.file.filename.to_s,
content_type: source_attachment.file.content_type
)
end
end
# Extracts content attributes from the given params.
# - Converts ActionController::Parameters to a regular hash if needed.
# - Attempts to parse a JSON string if content is a string.
@@ -58,6 +94,18 @@ class Messages::MessageBuilder
{}
end
def process_content_attributes
@automation_rule = content_attributes&.dig(:automation_rule_id)
return unless @params.instance_of?(ActionController::Parameters)
@forwarded_message_id = content_attributes&.dig(:forwarded_message_id)
@in_reply_to = content_attributes&.dig(:in_reply_to)
@items = content_attributes&.dig(:items)
# Store original content before any modifications
@params[:content_original] = @params[:content].dup if @params[:content].present?
end
def process_attachments
return if @attachments.blank?
@@ -151,6 +199,11 @@ class Messages::MessageBuilder
in_reply_to: @in_reply_to,
echo_id: @params[:echo_id],
source_id: @params[:source_id]
}.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params)
}
.merge(external_created_at)
.merge(automation_rule_id)
.merge(campaign_id)
.merge(template_params)
.merge(@forwarded_attributes || {})
end
end
@@ -0,0 +1,68 @@
class Api::V1::Profile::MfaController < Api::BaseController
before_action :check_mfa_feature_available
before_action :check_mfa_enabled, only: [:destroy, :backup_codes]
before_action :check_mfa_disabled, only: [:create, :verify]
before_action :validate_otp, only: [:verify, :backup_codes, :destroy]
before_action :validate_password, only: [:destroy]
def show; end
def create
mfa_service.enable_two_factor!
end
def verify
@backup_codes = mfa_service.verify_and_activate!
end
def destroy
mfa_service.disable_two_factor!
end
def backup_codes
@backup_codes = mfa_service.generate_backup_codes!
end
private
def mfa_service
@mfa_service ||= Mfa::ManagementService.new(user: current_user)
end
def check_mfa_enabled
render_could_not_create_error(I18n.t('errors.mfa.not_enabled')) unless current_user.mfa_enabled?
end
def check_mfa_feature_available
return if Chatwoot.mfa_enabled?
render json: {
error: I18n.t('errors.mfa.feature_unavailable')
}, status: :forbidden
end
def check_mfa_disabled
render_could_not_create_error(I18n.t('errors.mfa.already_enabled')) if current_user.mfa_enabled?
end
def validate_otp
authenticated = Mfa::AuthenticationService.new(
user: current_user,
otp_code: mfa_params[:otp_code]
).authenticate
return if authenticated
render_could_not_create_error(I18n.t('errors.mfa.invalid_code'))
end
def validate_password
return if current_user.valid_password?(mfa_params[:password])
render_could_not_create_error(I18n.t('errors.mfa.invalid_credentials'))
end
def mfa_params
params.permit(:otp_code, :password)
end
end
@@ -9,13 +9,11 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
end
def create
# Authenticate user via the temporary sso auth token
if params[:sso_auth_token].present? && @resource.present?
authenticate_resource_with_sso_token
yield @resource if block_given?
render_create_success
else
super
return handle_mfa_verification if mfa_verification_request?
return handle_sso_authentication if sso_authentication_request?
super do |resource|
return handle_mfa_required(resource) if resource&.mfa_enabled?
end
end
@@ -25,6 +23,20 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
private
def mfa_verification_request?
params[:mfa_token].present?
end
def sso_authentication_request?
params[:sso_auth_token].present? && @resource.present?
end
def handle_sso_authentication
authenticate_resource_with_sso_token
yield @resource if block_given?
render_create_success
end
def login_page_url(error: nil)
frontend_url = ENV.fetch('FRONTEND_URL', nil)
@@ -46,6 +58,41 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
user = User.from_email(params[:email])
@resource = user if user&.valid_sso_auth_token?(params[:sso_auth_token])
end
def handle_mfa_required(resource)
render json: {
mfa_required: true,
mfa_token: Mfa::TokenService.new(user: resource).generate_token
}, status: :partial_content
end
def handle_mfa_verification
user = Mfa::TokenService.new(token: params[:mfa_token]).verify_token
return render_mfa_error('errors.mfa.invalid_token', :unauthorized) unless user
authenticated = Mfa::AuthenticationService.new(
user: user,
otp_code: params[:otp_code],
backup_code: params[:backup_code]
).authenticate
return render_mfa_error('errors.mfa.invalid_code') unless authenticated
sign_in_mfa_user(user)
end
def sign_in_mfa_user(user)
@resource = user
@token = @resource.create_token
@resource.save!
sign_in(:user, @resource, store: false, bypass: false)
render_create_success
end
def render_mfa_error(message_key, status = :bad_request)
render json: { error: I18n.t(message_key) }, status: status
end
end
DeviseOverrides::SessionsController.prepend_mod_with('DeviseOverrides::SessionsController')
@@ -6,11 +6,11 @@ class CaptainResponses extends ApiClient {
super('captain/assistant_responses', { accountScoped: true });
}
get({ page = 1, searchKey, assistantId, documentId, status } = {}) {
get({ page = 1, search, assistantId, documentId, status } = {}) {
return axios.get(this.url, {
params: {
page,
searchKey,
search,
assistant_id: assistantId,
document_id: documentId,
status,
+28
View File
@@ -0,0 +1,28 @@
/* global axios */
import ApiClient from './ApiClient';
class MfaAPI extends ApiClient {
constructor() {
super('profile/mfa', { accountScoped: false });
}
enable() {
return axios.post(`${this.url}`);
}
verify(otpCode) {
return axios.post(`${this.url}/verify`, { otp_code: otpCode });
}
disable(password, otpCode) {
return axios.delete(this.url, {
data: { password, otp_code: otpCode },
});
}
regenerateBackupCodes(otpCode) {
return axios.post(`${this.url}/backup_codes`, { otp_code: otpCode });
}
}
export default new MfaAPI();
@@ -148,6 +148,7 @@ const keyboardEvents = {
emit('sendMessage');
}
},
allowOnFocusedInput: true,
},
'$mod+Enter': {
action: () => {
@@ -159,6 +160,7 @@ const keyboardEvents = {
emit('sendMessage');
}
},
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents);
@@ -0,0 +1,34 @@
<script setup>
import Guardrails from './Guardrails.vue';
import Scenarios from './Scenarios.vue';
import ResponseGuidelines from './ResponseGuidelines.vue';
import Settings from './Settings.vue';
</script>
<template>
<Story
title="Captain/AnimatingImg/AnimatingImg"
:layout="{ type: 'grid', width: '300px' }"
>
<Variant title="Guardrails">
<div class="p-4 bg-n-background w-full h-full">
<Guardrails class="size-60" />
</div>
</Variant>
<Variant title="Scenarios">
<div class="p-4 bg-n-background w-full h-full">
<Scenarios class="size-60" />
</div>
</Variant>
<Variant title="ResponseGuidelines">
<div class="p-4 bg-n-background w-full h-full">
<ResponseGuidelines class="size-60" />
</div>
</Variant>
<Variant title="Settings">
<div class="p-4 bg-n-background w-full h-full">
<Settings class="size-60" />
</div>
</Variant>
</Story>
</template>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,990 @@
<script setup>
import { ref } from 'vue';
const paused = ref(false);
const toggle = () => {
paused.value = !paused.value;
};
</script>
<template>
<div
class="svg-wrapper relative"
:class="{ paused }"
role="button"
:aria-pressed="paused"
tabindex="0"
@click="toggle"
>
<div class="absolute z-0 flex-shrink-0">
<svg
width="auto"
height="auto"
viewBox="0 0 200 156"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g opacity="0.5">
<g clip-path="url(#clip0_773_34322)">
<circle cx="8" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="153" r="1" class="fill-n-blue-9" />
<rect
width="200"
height="156"
fill="url(#paint0_linear_773_34322)"
/>
<rect
width="200"
height="156"
fill="url(#paint1_linear_773_34322)"
/>
</g>
</g>
<defs>
<linearGradient
id="paint0_linear_773_34322"
x1="100"
y1="0"
x2="100"
y2="156"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="var(--gradient-start)" />
<stop
offset="0.480769"
stop-color="var(--gradient-start)"
stop-opacity="0"
/>
<stop offset="1" stop-color="var(--gradient-start)" />
</linearGradient>
<linearGradient
id="paint1_linear_773_34322"
x1="0"
y1="78"
x2="200"
y2="78"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="var(--gradient-start)" />
<stop
offset="0.480769"
stop-color="var(--gradient-start)"
stop-opacity="0"
/>
<stop offset="1" stop-color="var(--gradient-start)" />
</linearGradient>
<clipPath id="clip0_773_34322">
<rect width="200" height="156" rx="16" fill="white" />
</clipPath>
</defs>
</svg>
</div>
<svg
viewBox="0 0 136 108"
aria-hidden="false"
focusable="false"
class="z-10 relative flex-shrink-0"
>
<rect width="136" height="108" fill="url(#paint0_radial_720_25701)" />
<path
d="M49 35.039C49 33.9129 49.9129 33 51.039 33C52.1651 33 53.0779 33.9129 53.0779 35.039V39.7513C53.0779 40.8774 52.1651 41.7902 51.039 41.7902C49.9129 41.7902 49 40.8774 49 39.7513V35.039Z"
class="fill-n-slate-10"
/>
<path
d="M55.5244 35.039C55.5244 33.9129 56.4373 33 57.5634 33C58.6895 33 59.6024 33.9129 59.6024 35.039V39.7513C59.6024 40.8774 58.6895 41.7902 57.5634 41.7902C56.4373 41.7902 55.5244 40.8774 55.5244 39.7513V35.039Z"
class="fill-n-slate-10"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M58.1862 24.173C50.5933 23.1017 44.4039 23.0594 36.6405 24.1683C34.678 24.4485 33.3796 24.638 32.3896 24.9273C31.4716 25.1956 30.9496 25.5155 30.5177 25.9981C29.6236 26.9974 29.5057 28.1276 29.3838 32.0695C29.264 35.9436 29.5013 39.4156 29.9507 43.3222C30.1901 45.4039 30.3549 46.8088 30.618 47.8769C30.8676 48.89 31.1694 49.4347 31.5889 49.8518C32.0117 50.2721 32.5536 50.5686 33.5482 50.8091C34.6002 51.0636 35.9806 51.2175 38.032 51.4413C44.6668 52.1652 49.6333 52.1615 56.2927 51.4451C58.369 51.2218 59.7719 51.0678 60.8396 50.815C61.8541 50.5749 62.3995 50.2805 62.8165 49.8722C63.2247 49.4724 63.5366 48.921 63.8071 47.8574C64.0894 46.7477 64.2815 45.2793 64.5594 43.1222C65.0553 39.2735 65.4123 35.8935 65.4262 32.1908C65.4411 28.232 65.3601 27.1056 64.4558 26.0608C64.019 25.5562 63.488 25.2244 62.5491 24.9481C61.5352 24.6497 60.2017 24.4573 58.1862 24.173ZM36.1664 20.849C44.2537 19.6939 50.7606 19.7391 58.6546 20.8529L58.7701 20.8692C60.6392 21.1328 62.2183 21.3555 63.4958 21.7315C64.8745 22.1372 66.0274 22.7532 66.991 23.8665C68.8087 25.9666 68.7969 28.4583 68.7811 31.7688C68.7804 31.9121 68.7797 32.057 68.7792 32.2034C68.7645 36.1184 68.3857 39.6634 67.8849 43.5506L67.8717 43.6535C67.6103 45.682 67.395 47.3537 67.0566 48.6839C66.7008 50.0827 66.169 51.282 65.1624 52.2678C64.1645 53.245 62.9854 53.7528 61.6119 54.0778C60.3149 54.3848 58.7011 54.5584 56.7558 54.7676L56.6513 54.7789C49.7551 55.5207 44.5465 55.5249 37.6684 54.7745L37.5622 54.7629C35.6442 54.5537 34.0471 54.3795 32.7599 54.0681C31.3915 53.7371 30.2202 53.219 29.2249 52.2296C28.2263 51.2369 27.7018 50.0571 27.3623 48.6788C27.0421 47.3788 26.856 45.7601 26.6318 43.8109L26.6197 43.7053C26.1588 39.6989 25.906 36.0546 26.0325 31.9659C26.037 31.8176 26.0414 31.6709 26.0458 31.5257C26.144 28.24 26.2178 25.7754 28.019 23.7623C28.9747 22.6942 30.1043 22.102 31.449 21.7089C32.6962 21.3444 34.2339 21.1249 36.0541 20.865C36.0914 20.8597 36.1288 20.8543 36.1664 20.849Z"
class="fill-n-slate-10"
/>
<path
d="M103.999 77.4062H108.999C110.104 77.4062 110.999 78.3017 110.999 79.4062C110.999 80.5108 110.104 81.4062 108.999 81.4062H103.999V77.4062Z"
class="fill-n-slate-9"
/>
<path
d="M63 77.4062H58C56.8954 77.4062 56 78.3017 56 79.4062C56 80.5108 56.8954 81.4062 58 81.4062H63V77.4062Z"
class="fill-n-slate-9"
/>
<path
d="M83.999 64.4063C85.4527 63.7978 86.4227 63.4566 87.9986 63.4508C89.5744 63.445 91.136 63.7496 92.5941 64.3472C94.0523 64.9449 95.3784 65.8239 96.4968 66.9341C97.6153 68.0442 98.5041 69.3638 99.1125 70.8175C99.721 72.2711 100.037 73.8304 100.043 75.4062"
class="stroke-n-blue-11 fill-n-slate-2"
stroke-width="1.6"
/>
<path
d="M67.049 75.0889C67.0432 73.5131 67.3478 71.9515 67.9454 70.4934C68.5431 69.0352 69.4221 67.7091 70.5322 66.5907C71.6424 65.4723 72.962 64.5834 74.4157 63.975C75.8693 63.3666 77.4286 63.0504 79.0044 63.0445C80.5803 63.0387 82.1419 63.3433 83.6 63.941C85.0581 64.5386 86.3843 65.4176 87.5027 66.5278C88.6211 67.638 89.5099 68.9576 90.1184 70.4112C90.7268 71.8649 91.043 73.4241 91.0488 75"
class="stroke-n-blue-11 fill-n-slate-2"
stroke-width="1.6"
/>
<path
d="M71.4659 74.9421C71.462 73.8915 71.6651 72.8505 72.0635 71.8784C72.462 70.9063 73.048 70.0222 73.7881 69.2766C74.5282 68.531 75.4079 67.9384 76.377 67.5328C77.3462 67.1272 78.3857 66.9164 79.4362 66.9125C80.4868 66.9086 81.5278 67.1117 82.4999 67.5101C83.472 67.9086 84.3561 68.4946 85.1017 69.2347C85.8473 69.9748 86.4399 70.8545 86.8455 71.8236C87.2511 72.7927 87.4619 73.8322 87.4658 74.8828"
class="stroke-n-blue-11 fill-n-slate-2"
stroke-width="1.6"
/>
<path
d="M88.4304 67.3188C89.4809 67.3149 90.522 67.5179 91.4941 67.9164C92.4662 68.3148 93.3503 68.9008 94.0959 69.6409C94.8415 70.381 95.434 71.2608 95.8397 72.2299C96.2453 73.199 96.4561 74.2385 96.46 75.2891"
class="stroke-n-blue-11 fill-n-slate-2"
stroke-width="1.6"
/>
<path
d="M75.9473 74.9203C75.9454 74.395 76.0469 73.8745 76.2461 73.3884C76.4453 72.9024 76.7383 72.4603 77.1084 72.0875C77.4785 71.7147 77.9183 71.4184 78.4029 71.2156C78.8874 71.0128 79.4072 70.9074 79.9325 70.9055C80.4578 70.9035 80.9783 71.0051 81.4643 71.2043C81.9504 71.4035 82.3924 71.6965 82.7652 72.0666C83.138 72.4366 83.4343 72.8765 83.6371 73.361C83.8399 73.8456 83.9453 74.3653 83.9473 74.8906"
class="stroke-n-blue-11 fill-n-slate-2"
stroke-width="1.6"
/>
<path
d="M90.4585 71.6066C90.9445 71.8058 91.3866 72.0988 91.7594 72.4689C92.1322 72.839 92.4284 73.2788 92.6313 73.7634C92.8341 74.2479 92.9395 74.7677 92.9414 75.293"
class="stroke-n-blue-11"
stroke-width="1.6"
/>
<path
d="M102 74.9062C103.09 74.9062 104.032 75.7999 103.973 76.957C103.853 79.29 103.335 81.588 102.439 83.751C101.939 84.9592 101.499 85.8713 100.931 86.7236C100.363 87.5763 99.6906 88.3338 98.7646 89.2598C96.6889 91.3355 93.3531 92.0527 91.0576 92.0527H76.5576C73.9588 92.0527 70.4405 91.3497 68.3506 89.2598C67.4349 88.3441 66.737 87.5905 66.1416 86.7441C65.5415 85.8911 65.0682 84.9765 64.5605 83.751C63.6646 81.588 63.1471 79.29 63.0273 76.957C62.968 75.7999 63.9098 74.9062 65 74.9062H102Z"
class="fill-n-slate-2 stroke-n-slate-9"
stroke-width="2"
/>
<g class="default-group">
<path
d="M103.85 21.1431C104.824 20.8821 105.847 21.3479 106.291 22.2542L112.209 34.3514C112.793 35.544 112.143 36.9734 110.861 37.3171L100.014 40.2235C98.7313 40.5669 97.4538 39.654 97.3628 38.3295L96.4399 24.8937C96.3708 23.8871 97.0247 22.9718 97.9993 22.7107L103.85 21.1431Z"
class="stroke-n-slate-11 fill-n-slate-2"
stroke-width="1.43699"
/>
<path
d="M104.528 18.8482C104.84 18.7645 105.124 18.5966 105.347 18.363C106.576 17.0748 105.336 14.9834 103.616 15.4439L95.3492 17.659C93.6293 18.1203 93.6011 20.5515 95.3101 21.0523C95.6201 21.1431 95.9494 21.1468 96.2614 21.0633L104.528 18.8482Z"
class="stroke-n-slate-11 fill-n-slate-2"
stroke-width="1.43699"
/>
<rect
x="95.3496"
y="21.2852"
width="10.4348"
height="2.08696"
rx="0.695652"
transform="rotate(-15 95.3496 21.2852)"
class="stroke-n-slate-11 fill-n-slate-2"
stroke-width="1.43699"
/>
<path
d="M103.249 14.4618C103.363 14.5616 103.464 14.6686 103.55 14.7819C103.782 15.0879 103.556 15.4819 103.185 15.5813L99.4893 16.5716L95.7936 17.5618C95.4224 17.6613 95.0296 17.4332 95.0777 17.052C95.0955 16.9109 95.1292 16.7679 95.1785 16.6242C95.3106 16.2393 95.5529 15.8568 95.8916 15.4986C96.2303 15.1403 96.6587 14.8133 97.1525 14.5362C97.6462 14.2592 98.1955 14.0375 98.7691 13.8838C99.3426 13.7301 99.9292 13.6474 100.495 13.6405C101.061 13.6336 101.596 13.7026 102.068 13.8435C102.541 13.9844 102.942 14.1945 103.249 14.4618Z"
class="stroke-n-slate-11 fill-n-slate-2"
stroke-width="1.43699"
/>
</g>
<g class="frame frame-1">
<path
d="M97.8899 24.7525C97.1819 24.0337 97.0822 22.9139 97.6517 22.0811L105.253 10.9636C106.002 9.8676 107.566 9.72734 108.498 10.6731L116.378 18.6731C117.31 19.619 117.146 21.1806 116.039 21.9133L104.809 29.3463C103.967 29.9032 102.848 29.7861 102.14 29.0673L97.8899 24.7525Z"
class="stroke-n-slate-11 fill-n-slate-2"
stroke-width="1.43699"
/>
<path
d="M95.5608 25.2908C95.3341 25.0607 95.0482 24.8971 94.735 24.8184C93.0078 24.3847 91.8006 26.4952 93.05 27.7641L99.0562 33.8613C100.306 35.1296 102.435 33.9543 102.027 32.2207C101.953 31.9063 101.794 31.6181 101.567 31.3879L95.5608 25.2908Z"
class="stroke-n-slate-11 fill-n-slate-2"
stroke-width="1.43699"
/>
<rect
x="102.207"
y="32.0742"
width="10.4348"
height="2.08696"
rx="0.695652"
transform="rotate(-134.569 102.207 32.0742)"
class="stroke-n-slate-11 fill-n-slate-2"
stroke-width="1.43699"
/>
<path
d="M92.3768 28.5691C92.4071 28.4202 92.4505 28.2799 92.5066 28.1492C92.6581 27.7962 93.1124 27.7985 93.3821 28.0722L96.0671 30.7979L98.7521 33.5236C99.0218 33.7973 99.0172 34.2515 98.6619 34.3978C98.5304 34.4519 98.3895 34.4932 98.2402 34.5212C97.8402 34.5963 97.3879 34.5743 96.9092 34.4565C96.4304 34.3387 95.9346 34.1274 95.45 33.8347C94.9654 33.542 94.5015 33.1737 94.0848 32.7506C93.668 32.3276 93.3067 31.8582 93.0213 31.3692C92.7359 30.8803 92.5321 30.3813 92.4216 29.9009C92.311 29.4204 92.2958 28.9679 92.3768 28.5691Z"
class="stroke-n-slate-11 fill-n-slate-2"
stroke-width="1.43699"
/>
<circle cx="85" cy="40" r="1" class="fill-n-slate-11" />
<circle cx="91" cy="38" r="1" class="fill-n-slate-11" />
<circle cx="91" cy="44" r="1" class="fill-n-slate-11" />
<circle cx="85" cy="46" r="1" class="fill-n-slate-11" />
<circle cx="79" cy="46" r="1" class="fill-n-slate-11" />
<circle cx="88" cy="51" r="1" class="fill-n-slate-11" />
</g>
<g class="frame frame-2">
<path
d="M97.8879 24.7721C97.1799 24.0532 97.0803 22.9335 97.6497 22.1006L105.251 10.9832C106 9.88713 107.564 9.74688 108.496 10.6927L116.376 18.6926C117.308 19.6385 117.144 21.2001 116.037 21.9329L104.807 29.3658C103.965 29.9227 102.846 29.8056 102.138 29.0868L97.8879 24.7721Z"
class="stroke-n-slate-11 fill-n-slate-2"
stroke-width="1.43699"
/>
<path
d="M95.5579 25.3103C95.3311 25.0803 95.0453 24.9167 94.7321 24.838C93.0048 24.4042 91.7977 26.5148 93.0471 27.7836L99.0532 33.8808C100.303 35.1491 102.432 33.9738 102.024 32.2403C101.95 31.9259 101.791 31.6376 101.564 31.4075L95.5579 25.3103Z"
class="stroke-n-slate-11 fill-n-slate-2"
stroke-width="1.43699"
/>
<rect
x="102.205"
y="32.0938"
width="10.4348"
height="2.08696"
rx="0.695652"
transform="rotate(-134.569 102.205 32.0938)"
class="stroke-n-slate-11 fill-n-slate-2"
stroke-width="1.43699"
/>
<path
d="M92.3758 28.5886C92.4061 28.4397 92.4495 28.2994 92.5056 28.1687C92.6572 27.8157 93.1115 27.818 93.3811 28.0917L96.0661 30.8174L98.7512 33.5431C99.0208 33.8168 99.0162 34.2711 98.661 34.4173C98.5294 34.4714 98.3885 34.5128 98.2392 34.5408C97.8392 34.6158 97.3869 34.5938 96.9082 34.476C96.4295 34.3582 95.9336 34.1469 95.449 33.8542C94.9644 33.5616 94.5005 33.1932 94.0838 32.7702C93.6671 32.3471 93.3057 31.8777 93.0203 31.3888C92.735 30.8998 92.5312 30.4009 92.4206 29.9204C92.31 29.44 92.2948 28.9874 92.3758 28.5886Z"
class="stroke-n-slate-11 fill-n-slate-2"
stroke-width="1.43699"
/>
<circle cx="84" cy="42" r="1" class="fill-n-slate-11" />
<circle cx="90" cy="40" r="1" class="fill-n-slate-11" />
<circle cx="90" cy="46" r="1" class="fill-n-slate-11" />
<circle cx="84" cy="48" r="1" class="fill-n-slate-11" />
<circle cx="78" cy="48" r="1" class="fill-n-slate-11" />
<circle cx="87" cy="53" r="1" class="fill-n-slate-11" />
</g>
<g class="frame frame-3">
<path
d="M97.8879 24.7486C97.1799 24.0298 97.0803 22.91 97.6497 22.0771L105.251 10.9597C106 9.86369 107.564 9.72344 108.496 10.6692L116.376 18.6692C117.308 19.6151 117.144 21.1767 116.037 21.9094L104.807 29.3424C103.965 29.8993 102.846 29.7822 102.138 29.0634L97.8879 24.7486Z"
class="stroke-n-slate-11 fill-n-slate-2"
stroke-width="1.43699"
/>
<path
d="M95.5579 25.2908C95.3311 25.0607 95.0453 24.8971 94.7321 24.8184C93.0048 24.3847 91.7977 26.4952 93.0471 27.7641L99.0532 33.8613C100.303 35.1296 102.432 33.9543 102.024 32.2207C101.95 31.9063 101.791 31.6181 101.564 31.3879L95.5579 25.2908Z"
class="stroke-n-slate-11 fill-n-slate-2"
stroke-width="1.43699"
/>
<rect
x="102.205"
y="32.0703"
width="10.4348"
height="2.08696"
rx="0.695652"
transform="rotate(-134.569 102.205 32.0703)"
class="stroke-n-slate-11 fill-n-slate-2"
stroke-width="1.43699"
/>
<path
d="M92.3758 28.5691C92.4061 28.4202 92.4495 28.2799 92.5056 28.1492C92.6572 27.7962 93.1115 27.7985 93.3811 28.0722L96.0661 30.7979L98.7512 33.5236C99.0208 33.7973 99.0162 34.2515 98.661 34.3978C98.5294 34.4519 98.3885 34.4932 98.2392 34.5212C97.8392 34.5963 97.3869 34.5743 96.9082 34.4565C96.4295 34.3387 95.9336 34.1274 95.449 33.8347C94.9644 33.542 94.5005 33.1737 94.0838 32.7506C93.6671 32.3276 93.3057 31.8582 93.0203 31.3692C92.735 30.8803 92.5312 30.3813 92.4206 29.9009C92.31 29.4204 92.2948 28.9679 92.3758 28.5691Z"
class="stroke-n-slate-11 fill-n-slate-2"
stroke-width="1.43699"
/>
<circle cx="82" cy="44" r="1" class="fill-n-slate-11" />
<circle cx="88" cy="42" r="1" class="fill-n-slate-11" />
<circle cx="88" cy="48" r="1" class="fill-n-slate-11" />
<circle cx="82" cy="50" r="1" class="fill-n-slate-11" />
<circle cx="76" cy="50" r="1" class="fill-n-slate-11" />
<circle cx="85" cy="55" r="1" class="fill-n-slate-11" />
</g>
<defs>
<radialGradient
id="paint0_radial_720_25701"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(68 54) rotate(90) scale(54 68)"
>
<stop
offset="0.527769"
stop-color="var(--gradient-end)"
stop-opacity="0.9"
/>
<stop offset="1" stop-color="var(--gradient-end)" stop-opacity="0" />
</radialGradient>
</defs>
</svg>
</div>
</template>
<style scoped>
svg {
--gradient-start: #fcfcfd;
--gradient-end: #fcfcfd;
}
body.dark svg,
.htw-dark svg {
--gradient-start: #121213;
--gradient-end: #121213;
}
.svg-wrapper {
display: flex;
align-items: center;
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
outline: none;
}
.default-group {
opacity: 0;
transition: opacity 120ms linear;
pointer-events: none;
}
.frame {
opacity: 0;
animation-name: frameVisible;
animation-duration: 600ms;
animation-iteration-count: infinite;
animation-timing-function: steps(1, end);
transform-origin: center;
}
.frame-1 {
animation-delay: 0ms;
}
.frame-2 {
animation-delay: 200ms;
}
.frame-3 {
animation-delay: 400ms;
}
@keyframes frameVisible {
0% {
opacity: 1;
}
33.333% {
opacity: 0;
}
100% {
opacity: 0;
}
}
.svg-wrapper.paused .frame {
animation-play-state: paused;
opacity: 0 !important;
}
.svg-wrapper.paused .default-group {
opacity: 1;
}
.svg-wrapper:not(.paused) .default-group {
opacity: 0;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,752 @@
<template>
<div class="svg-wrapper relative" tabindex="0">
<div class="absolute z-0 flex-shrink-0">
<svg
width="auto"
height="auto"
viewBox="0 0 200 156"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g opacity="0.5">
<g clip-path="url(#clip0_773_34322)">
<circle cx="8" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="3" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="9" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="15" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="21" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="27" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="33" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="39" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="45" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="51" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="57" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="63" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="69" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="75" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="81" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="87" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="93" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="99" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="105" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="111" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="117" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="123" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="129" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="135" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="141" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="147" r="1" class="fill-n-blue-9" />
<circle cx="8" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="16" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="24" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="32" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="40" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="48" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="56" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="64" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="72" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="80" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="88" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="96" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="104" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="112" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="120" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="128" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="136" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="144" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="152" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="160" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="168" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="176" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="184" cy="153" r="1" class="fill-n-blue-9" />
<circle cx="192" cy="153" r="1" class="fill-n-blue-9" />
<rect
width="200"
height="156"
fill="url(#paint0_linear_773_34322)"
/>
<rect
width="200"
height="156"
fill="url(#paint1_linear_773_34322)"
/>
</g>
</g>
<defs>
<linearGradient
id="paint0_linear_773_34322"
x1="100"
y1="0"
x2="100"
y2="156"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="var(--gradient-start)" />
<stop
offset="0.480769"
stop-color="var(--gradient-start)"
stop-opacity="0"
/>
<stop offset="1" stop-color="var(--gradient-start)" />
</linearGradient>
<linearGradient
id="paint1_linear_773_34322"
x1="0"
y1="78"
x2="200"
y2="78"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="var(--gradient-start)" />
<stop
offset="0.480769"
stop-color="var(--gradient-start)"
stop-opacity="0"
/>
<stop offset="1" stop-color="var(--gradient-start)" />
</linearGradient>
<clipPath id="clip0_773_34322">
<rect width="200" height="156" rx="16" fill="white" />
</clipPath>
</defs>
</svg>
</div>
<svg
viewBox="0 0 136 108"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="false"
focusable="false"
class="z-10 relative flex-shrink-0"
>
<rect width="136" height="108" fill="url(#paint0_radial_797_91519)" />
<path
d="M58 48.039C58 46.9129 58.9129 46 60.039 46C61.1651 46 62.0779 46.9129 62.0779 48.039V52.7513C62.0779 53.8774 61.1651 54.7902 60.039 54.7902C58.9129 54.7902 58 53.8774 58 52.7513V48.039Z"
class="fill-n-slate-10"
/>
<path
d="M64.5244 48.039C64.5244 46.9129 65.4373 46 66.5634 46C67.6895 46 68.6024 46.9129 68.6024 48.039V52.7513C68.6024 53.8774 67.6895 54.7902 66.5634 54.7902C65.4373 54.7902 64.5244 53.8774 64.5244 52.7513V48.039Z"
class="fill-n-slate-10"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M79.1862 40.173C71.5933 39.1017 65.4039 39.0594 57.6405 40.1683C55.678 40.4485 54.3796 40.638 53.3896 40.9273C52.4716 41.1956 51.9496 41.5155 51.5177 41.9981C50.6236 42.9974 50.5057 44.1276 50.3838 48.0695C50.264 51.9436 50.5013 55.4156 50.9507 59.3222C51.1901 61.4039 51.3549 62.8088 51.618 63.8769C51.8676 64.89 52.1694 65.4347 52.5889 65.8518C53.0117 66.2721 53.5536 66.5686 54.5482 66.8091C55.6002 67.0636 56.9806 67.2175 59.032 67.4413C65.6668 68.1652 70.6333 68.1615 77.2927 67.4451C79.369 67.2218 80.7719 67.0678 81.8396 66.815C82.8541 66.5749 83.3995 66.2805 83.8165 65.8722C84.2247 65.4724 84.5366 64.921 84.8071 63.8574C85.0894 62.7477 85.2815 61.2793 85.5594 59.1222C86.0553 55.2735 86.4123 51.8935 86.4262 48.1908C86.4411 44.232 86.3601 43.1056 85.4558 42.0608C85.019 41.5562 84.488 41.2244 83.5491 40.9481C82.5352 40.6497 81.2017 40.4573 79.1862 40.173ZM57.1664 36.849C65.2537 35.6939 71.7606 35.7391 79.6546 36.8529L79.7701 36.8692C81.6392 37.1328 83.2183 37.3555 84.4958 37.7315C85.8745 38.1372 87.0274 38.7532 87.991 39.8665C89.8087 41.9666 89.7969 44.4583 89.7811 47.7688C89.7804 47.9121 89.7797 48.057 89.7792 48.2034C89.7645 52.1184 89.3857 55.6634 88.8849 59.5506L88.8717 59.6535C88.6103 61.682 88.395 63.3537 88.0566 64.6839C87.7008 66.0827 87.169 67.282 86.1624 68.2678C85.1645 69.245 83.9854 69.7528 82.6119 70.0778C81.3149 70.3848 79.7011 70.5584 77.7558 70.7676L77.6513 70.7789C70.7551 71.5207 65.5465 71.5249 58.6684 70.7745L58.5622 70.7629C56.6442 70.5537 55.0471 70.3795 53.7599 70.0681C52.3915 69.7371 51.2202 69.219 50.2249 68.2296C49.2263 67.2369 48.7018 66.0571 48.3623 64.6788C48.0421 63.3788 47.856 61.7601 47.6318 59.8109L47.6197 59.7053C47.1588 55.6989 46.906 52.0546 47.0325 47.9659C47.037 47.8176 47.0414 47.6709 47.0458 47.5257C47.144 44.24 47.2178 41.7754 49.019 39.7623C49.9747 38.6942 51.1043 38.102 52.449 37.7089C53.6962 37.3444 55.2339 37.1249 57.0541 36.865C57.0914 36.8597 57.1288 36.8543 57.1664 36.849Z"
class="fill-n-slate-10"
/>
<defs>
<radialGradient
id="paint0_radial_797_91519"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(68 54) rotate(90) scale(54 68)"
>
<stop
offset="0.527769"
stop-color="var(--gradient-end)"
stop-opacity="0.9"
/>
<stop offset="1" stop-color="var(--gradient-end)" stop-opacity="0" />
</radialGradient>
</defs>
</svg>
</div>
</template>
<style scoped>
svg {
--gradient-start: #fcfcfd;
--gradient-end: #fcfcfd;
}
body.dark svg,
.htw-dark svg {
--gradient-start: #121213;
--gradient-end: #121213;
}
.svg-wrapper {
display: flex;
align-items: center;
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
outline: none;
}
</style>
@@ -7,6 +7,11 @@ const props = defineProps({
placeholder: { type: String, default: '' },
label: { type: String, default: '' },
id: { type: String, default: '' },
size: {
type: String,
default: 'md',
validator: value => ['sm', 'md'].includes(value),
},
message: { type: String, default: '' },
disabled: { type: Boolean, default: false },
messageType: {
@@ -69,6 +74,17 @@ const handleFocus = event => {
isFocused.value = true;
};
const sizeClass = computed(() => {
switch (props.size) {
case 'sm':
return 'h-8 !px-3 !py-2';
case 'md':
return 'h-10 !px-3 !py-2.5';
default:
return 'h-10 !px-3 !py-2.5';
}
});
const handleBlur = event => {
emit('blur', event);
isFocused.value = false;
@@ -100,11 +116,13 @@ onMounted(() => {
<slot name="prefix" />
<input
:id="uniqueId"
v-bind="$attrs"
ref="inputRef"
:value="modelValue"
:class="[
customInputClass,
inputOutlineClass,
sizeClass,
{
error: messageType === 'error',
focus: isFocused,
@@ -119,7 +137,7 @@ onMounted(() => {
? max
: undefined
"
class="block w-full reset-base text-sm h-10 !px-3 !py-2.5 !mb-0 outline outline-1 border-none border-0 outline-offset-[-1px] rounded-lg bg-n-alpha-black2 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 disabled:cursor-not-allowed disabled:opacity-50 text-n-slate-12 transition-all duration-500 ease-in-out [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
class="block w-full reset-base text-sm !mb-0 outline outline-1 border-none border-0 outline-offset-[-1px] rounded-lg bg-n-alpha-black2 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 disabled:cursor-not-allowed disabled:opacity-50 text-n-slate-12 transition-all duration-500 ease-in-out [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@@ -1,5 +1,5 @@
<script setup>
import { onMounted, computed, ref, toRefs } from 'vue';
import { onMounted, computed, ref, toRefs, useTemplateRef } from 'vue';
import { useTimeoutFn } from '@vueuse/core';
import { provideMessageContext } from './provider.js';
import { useTrack } from 'dashboard/composables';
@@ -136,6 +136,7 @@ const emit = defineEmits(['retry']);
const contextMenuPosition = ref({});
const showBackgroundHighlight = ref(false);
const showContextMenu = ref(false);
const emailBubbleRef = useTemplateRef('emailBubbleRef');
const { t } = useI18n();
const route = useRoute();
@@ -364,6 +365,16 @@ const contextMenuEnabledOptions = computed(() => {
!props.private &&
props.inboxSupportsReplyTo.outgoing &&
!isFailedOrProcessing,
// Forward email is enabled only when the message is not in progress and is not private and is an email inbox
forwardEmail:
props.isEmailInbox &&
!props.private &&
props.status !== MESSAGE_STATUS.PROGRESS &&
![
CONTENT_TYPES.FORM,
CONTENT_TYPES.INPUT_CSAT,
CONTENT_TYPES.CARDS,
].includes(props.contentType),
};
});
@@ -384,6 +395,9 @@ const shouldRenderMessage = computed(() => {
});
function openContextMenu(e) {
// Close forward modal, when opening context menu
emailBubbleRef.value?.closeForwardModal();
const shouldSkipContextMenu =
e.target?.classList.contains('skip-context-menu') ||
['a', 'img'].includes(e.target?.tagName.toLowerCase());
@@ -459,6 +473,11 @@ const setupHighlightTimer = () => {
}, HIGHLIGHT_TIMER);
};
const openForwardModal = ({ x, y }) => {
// Open forward modal, with the event from context menu
emailBubbleRef.value.openForwardModal({ x, y });
};
onMounted(setupHighlightTimer);
provideMessageContext({
@@ -519,7 +538,12 @@ provideMessageContext({
}"
@contextmenu="openContextMenu($event)"
>
<Component :is="componentToRender" />
<component
:is="componentToRender"
:ref="
componentToRender === EmailBubble ? 'emailBubbleRef' : undefined
"
/>
</div>
<MessageError
v-if="contentAttributes.externalError"
@@ -540,6 +564,7 @@ provideMessageContext({
@open="openContextMenu"
@close="closeContextMenu"
@reply-to="handleReplyTo"
@forward-email="openForwardModal"
/>
</div>
</div>
@@ -97,6 +97,16 @@ const getInReplyToMessage = parentMessage => {
return replyMessage ? useCamelCase(replyMessage) : null;
};
/**
* Gets the address of the forwarded message
* @param {Object} message - The message containing the forwarded message reference
* @returns {Array|null} - The email addresses of the forwarded message, or null if not forwarded
*/
const getForwardedMessageAddress = message => {
const { forwardedMessageId, toEmails } = message.contentAttributes || {};
return forwardedMessageId ? toEmails : null;
};
</script>
<template>
@@ -107,6 +117,11 @@ const getInReplyToMessage = parentMessage => {
v-if="firstUnreadId && message.id === firstUnreadId"
name="unreadBadge"
/>
<slot
v-if="getForwardedMessageAddress(message)"
:address="getForwardedMessageAddress(message)"
name="forwardedMessageAddress"
/>
<Message
v-bind="message"
:is-email-inbox="isAnEmailChannel"
@@ -14,8 +14,8 @@ const fromEmail = computed(() => {
});
const toEmail = computed(() => {
const { toEmails, email } = contentAttributes.value;
return email?.to ?? toEmails ?? [];
const { forwardedMessageId, toEmails, email } = contentAttributes.value;
return forwardedMessageId ? (toEmails ?? []) : (email?.to ?? []);
});
const ccEmail = computed(() => {
@@ -82,7 +82,7 @@ const showMeta = computed(() => {
&lt;{{ fromEmail[0] }}&gt;
</template>
<template v-else>
{{ fromEmail[0] }}
{{ $t('EMAIL_HEADER.FROM') }}: {{ fromEmail[0] }}
</template>
</div>
<div v-if="toEmail.length">
@@ -1,8 +1,9 @@
<script setup>
import { computed, useTemplateRef, ref, onMounted } from 'vue';
import { computed, useTemplateRef, ref, onMounted, reactive } from 'vue';
import { Letter } from 'vue-letter';
import { sanitizeTextForRender } from '@chatwoot/utils';
import { allowedCssProperties } from 'lettersanitizer';
import { useToggle } from '@vueuse/core';
import Icon from 'next/icon/Icon.vue';
import { EmailQuoteExtractor } from './removeReply.js';
@@ -11,20 +12,29 @@ import FormattedContent from 'next/message/bubbles/Text/FormattedContent.vue';
import AttachmentChips from 'next/message/chips/AttachmentChips.vue';
import EmailMeta from './EmailMeta.vue';
import TranslationToggle from 'dashboard/components-next/message/TranslationToggle.vue';
import ForwardMessageForm from 'dashboard/components-next/message/forwardMessage/ForwardMessage.vue';
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
import { useMessageContext } from '../../provider.js';
import { useInbox } from 'dashboard/composables/useInbox';
import { MESSAGE_TYPES } from 'next/message/constants.js';
import { useTranslations } from 'dashboard/composables/useTranslations';
const { content, contentAttributes, attachments, messageType } =
const { id, content, contentAttributes, attachments, messageType } =
useMessageContext();
const { inbox } = useInbox();
const isExpandable = ref(false);
const isExpanded = ref(false);
const showQuotedMessage = ref(false);
const renderOriginal = ref(false);
const contentContainer = useTemplateRef('contentContainer');
// Forward form - managed locally but can be triggered by parent
const [showForwardMessageModal, toggleForwardModal] = useToggle();
const forwardFormPosition = reactive({ top: 0, right: 0 });
onMounted(() => {
isExpandable.value = contentContainer.value?.scrollHeight > 400;
});
@@ -32,6 +42,8 @@ onMounted(() => {
const isOutgoing = computed(() => messageType.value === MESSAGE_TYPES.OUTGOING);
const isIncoming = computed(() => !isOutgoing.value);
const isForwarded = computed(() => contentAttributes.value?.forwardedMessageId);
const { hasTranslations, translationContent } =
useTranslations(contentAttributes);
@@ -93,6 +105,23 @@ const translationKeySuffix = computed(() => {
const handleSeeOriginal = () => {
renderOriginal.value = !renderOriginal.value;
};
const closeForwardModal = () => {
toggleForwardModal(false);
forwardFormPosition.x = 0;
forwardFormPosition.y = 0;
};
const openForwardModal = ({ x, y }) => {
forwardFormPosition.x = x;
forwardFormPosition.y = y;
toggleForwardModal(true);
};
defineExpose({
openForwardModal,
closeForwardModal,
});
</script>
<template>
@@ -111,6 +140,7 @@ const handleSeeOriginal = () => {
'border-b border-n-slate-8/20': isOutgoing,
}"
/>
<section ref="contentContainer" class="p-3">
<div
:class="{
@@ -137,7 +167,7 @@ const handleSeeOriginal = () => {
</button>
</div>
<FormattedContent
v-if="isOutgoing && content"
v-if="isOutgoing && content && !isForwarded"
class="text-n-slate-12"
:content="messageContent"
/>
@@ -200,6 +230,22 @@ const handleSeeOriginal = () => {
>
<AttachmentChips :attachments="attachments" class="gap-1" />
</section>
<ContextMenu
v-if="showForwardMessageModal"
:x="forwardFormPosition.x"
:y="forwardFormPosition.y"
@close="closeForwardModal"
>
<ForwardMessageForm
:message="contentAttributes?.email"
:content="content"
:inbox="inbox"
:attachments="attachments"
:message-id="id"
@close="closeForwardModal"
/>
</ContextMenu>
</BaseBubble>
</template>
@@ -1,8 +1,16 @@
<script setup>
import { computed, onMounted, useTemplateRef, ref } from 'vue';
import {
computed,
onMounted,
useTemplateRef,
ref,
getCurrentInstance,
} from 'vue';
import Icon from 'next/icon/Icon.vue';
import { timeStampAppendedURL } from 'dashboard/helper/URLHelper';
import { downloadFile } from '@chatwoot/utils';
import { useEmitter } from 'dashboard/composables/emitter';
import { emitter } from 'shared/helpers/mitt';
const { attachment } = defineProps({
attachment: {
@@ -27,6 +35,8 @@ const currentTime = ref(0);
const duration = ref(0);
const playbackSpeed = ref(1);
const { uid } = getCurrentInstance();
const onLoadedMetadata = () => {
duration.value = audioPlayer.value?.duration;
};
@@ -43,6 +53,18 @@ onMounted(() => {
audioPlayer.value.playbackRate = playbackSpeed.value;
});
// Listen for global audio play events and pause if it's not this audio
useEmitter('pause_playing_audio', currentPlayingId => {
if (currentPlayingId !== uid && isPlaying.value) {
try {
audioPlayer.value.pause();
} catch {
/* ignore pause errors */
}
isPlaying.value = false;
}
});
const formatTime = time => {
if (!time || Number.isNaN(time)) return '00:00';
const minutes = Math.floor(time / 60);
@@ -70,6 +92,8 @@ const playOrPause = () => {
audioPlayer.value.pause();
isPlaying.value = false;
} else {
// Emit event to pause all other audio
emitter.emit('pause_playing_audio', uid);
audioPlayer.value.play();
isPlaying.value = true;
}
@@ -101,6 +125,7 @@ const downloadAudio = async () => {
ref="audioPlayer"
controls
class="hidden"
playsinline
@loadedmetadata="onLoadedMetadata"
@timeupdate="onTimeUpdate"
@ended="onEnd"
@@ -0,0 +1,185 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { vOnClickOutside } from '@vueuse/components';
import { EmailQuoteExtractor } from 'dashboard/components-next/message/bubbles/Email/removeReply.js';
import { debounce } from '@chatwoot/utils';
import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import {
searchContacts,
createNewContact,
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
import ForwardMessageForm from './components/ForwardMessageForm.vue';
const props = defineProps({
forwardType: {
type: String,
default: 'email',
},
message: {
type: Object,
default: () => ({}),
},
content: {
type: String,
default: '',
},
inbox: {
type: Object,
default: () => ({}),
},
messageId: {
type: Number,
default: null,
},
attachments: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['close']);
const { t } = useI18n();
const store = useStore();
const contacts = ref([]);
const selectedContact = ref(null);
const isCreatingContact = ref(false);
const isSearching = ref(false);
const messageSignature = useMapGetter('getMessageSignature');
const currentChat = useMapGetter('getSelectedChat');
const currentUser = useMapGetter('getCurrentUser');
const globalConfig = useMapGetter('globalConfig/get');
const fromEmail = computed(() => props.inbox?.email);
const fullHTML = computed(() => {
return (
props.message?.htmlContent?.full ??
props.message?.textContent?.full?.replace(/\n/g, '<br>')
);
});
const unquotedHTML = computed(() =>
EmailQuoteExtractor.extractQuotes(fullHTML.value)
);
const hasQuotedMessage = computed(() =>
EmailQuoteExtractor.hasQuotes(fullHTML.value)
);
const textToShow = computed(() => {
const text = props.message?.textContent?.full;
return text?.replace(/\n/g, '<br>');
});
const onContactSearch = debounce(
async query => {
isSearching.value = true;
contacts.value = [];
try {
contacts.value = await searchContacts(query);
isSearching.value = false;
} catch (error) {
useAlert(t('FORWARD_MESSAGE_FORM.CONTACT_SEARCH.ERROR_MESSAGE'));
} finally {
isSearching.value = false;
}
},
300,
false
);
const handleClickOutside = () => {
selectedContact.value = null;
emit('close');
};
const handleForwardMessage = async ({ state }) => {
try {
const messagePayload = {
conversationId: currentChat.value?.id,
message: state.message,
toEmails: selectedContact.value?.email,
private: false,
contentAttributes: {
forwarded_message_id: props.messageId,
},
sender: {
name: currentUser.value?.name,
thumbnail: currentUser.value?.avatar_url,
},
files: globalConfig.value?.directUploadsEnabled
? state.attachedFiles.map(file => file.blobSignedId)
: state.attachedFiles.map(file => file.resource.file),
};
await store.dispatch('createPendingMessageAndSend', messagePayload);
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
emitter.emit(BUS_EVENTS.MESSAGE_SENT);
// Close the forward message modal after sending
emit('close');
} catch (error) {
const errorMessage =
error?.response?.data?.error ||
t('FORWARD_MESSAGE_FORM.FORWARD_MESSAGE.ERROR_MESSAGE');
useAlert(errorMessage);
}
};
const handleSelectedContact = async ({ value, action, ...rest }) => {
let contact;
if (action === 'create') {
isCreatingContact.value = true;
try {
contact = await createNewContact(value);
isCreatingContact.value = false;
} catch (error) {
isCreatingContact.value = false;
return;
}
} else {
contact = rest;
}
selectedContact.value = contact;
};
</script>
<template>
<div
v-on-click-outside="[
handleClickOutside,
// Fixed and edge case https://github.com/chatwoot/chatwoot/issues/10785
// This will prevent closing the compose conversation modal when the editor Create link popup is open
{ ignore: ['div.ProseMirror-prompt'] },
]"
>
<ForwardMessageForm
:forward-type="forwardType"
:contacts="contacts"
:selected-contact="selectedContact"
:is-loading="isSearching"
:is-creating-contact="isCreatingContact"
:from-email="fromEmail"
:message="message"
:attachments="attachments"
:message-signature="messageSignature"
:content="content"
:is-plain-email="!message || !Object.keys(message).length"
:full-html="fullHTML"
:unquoted-html="unquotedHTML"
:text-to-show="textToShow"
:has-quoted-message="hasQuotedMessage"
@search-contacts="onContactSearch"
@update-selected-contact="handleSelectedContact"
@clear-selected-contact="selectedContact = null"
@discard="emit('close')"
@forward-message="handleForwardMessage"
/>
</div>
</template>
@@ -0,0 +1,97 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { Letter } from 'vue-letter';
import { allowedCssProperties } from 'lettersanitizer';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import EmailMeta from 'dashboard/components-next/message/bubbles/Email/EmailMeta.vue';
import FormattedContent from 'next/message/bubbles/Text/FormattedContent.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
defineProps({
content: { type: String, default: '' },
isPlainEmail: { type: Boolean, default: false },
hasQuotedMessage: { type: Boolean, default: false },
fullHtml: { type: String, default: '' },
unquotedHtml: { type: String, default: '' },
textToShow: { type: String, default: '' },
});
const { t } = useI18n();
const modelValue = defineModel({
type: String,
default: '',
});
const showQuotedMessage = ref(false);
</script>
<template>
<div class="flex-1 h-full">
<Editor
v-model="modelValue"
:placeholder="t('FORWARD_MESSAGE_FORM.EMAIL_EDITOR_PLACEHOLDER')"
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[200px] [&_.ProseMirror-woot-style]:!min-h-fit"
enable-variables
:show-character-count="false"
/>
<div class="px-4 pb-4 flex flex-col gap-2">
<div class="flex items-center gap-1.5">
<span class="text-sm text-n-slate-12">
{{ t('FORWARD_MESSAGE_FORM.FORWARDED_MESSAGE') }}
</span>
</div>
<EmailMeta />
</div>
<div class="px-4 pb-4">
<FormattedContent
v-if="isPlainEmail"
class="text-n-slate-12"
:content="content"
/>
<template v-else>
<Letter
v-if="showQuotedMessage"
class-name="prose prose-bubble !max-w-none letter-render"
:allowed-css-properties="[
...allowedCssProperties,
'transform',
'transform-origin',
]"
:html="fullHtml"
:text="textToShow"
/>
<Letter
v-else
class-name="prose prose-bubble !max-w-none letter-render"
:html="unquotedHtml"
:allowed-css-properties="[
...allowedCssProperties,
'transform',
'transform-origin',
]"
:text="textToShow"
/>
</template>
<button
v-if="hasQuotedMessage"
class="text-n-slate-11 px-1 leading-none text-sm bg-n-alpha-black2 text-center flex items-center gap-1 mt-2"
@click="showQuotedMessage = !showQuotedMessage"
>
<template v-if="showQuotedMessage">
{{ t('FORWARD_MESSAGE_FORM.HIDE_QUOTED_TEXT') }}
</template>
<template v-else>
{{ t('FORWARD_MESSAGE_FORM.SHOW_QUOTED_TEXT') }}
</template>
<Icon
:icon="
showQuotedMessage ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'
"
/>
</button>
</div>
</div>
</template>
@@ -0,0 +1,213 @@
<script setup>
import { ref, computed, reactive } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { useI18n } from 'vue-i18n';
import { required } from '@vuelidate/validators';
import { buildContactableInboxesList } from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper.js';
import {
appendSignature,
removeSignature,
} from 'dashboard/helper/editorHelper';
import ContactSelector from 'dashboard/components-next/NewConversation/components/ContactSelector.vue';
import ActionButtons from 'dashboard/components-next/NewConversation/components/ActionButtons.vue';
import AttachmentPreviews from 'dashboard/components-next/NewConversation/components/AttachmentPreviews.vue';
import EmailMessageEditor from './EmailMessageEditor.vue';
import AttachmentChips from 'next/message/chips/AttachmentChips.vue';
const props = defineProps({
forwardType: { type: String, default: 'email' }, // eslint-disable-line vue/no-unused-properties
contacts: { type: Array, default: () => [] },
selectedContact: { type: Object, default: null },
isLoading: { type: Boolean, default: false },
isCreatingContact: { type: Boolean, default: false },
fromEmail: { type: String, default: null },
messageSignature: { type: String, default: '' },
content: { type: String, default: '' },
isPlainEmail: { type: Boolean, default: false },
fullHtml: { type: String, default: '' },
unquotedHtml: { type: String, default: '' },
textToShow: { type: String, default: '' },
hasQuotedMessage: { type: Boolean, default: false },
attachments: { type: Array, default: () => [] },
});
const emit = defineEmits([
'searchContacts',
'updateSelectedContact',
'clearSelectedContact',
'discard',
'forwardMessage',
]);
const { t } = useI18n();
const state = reactive({
message: '',
attachedFiles: [],
});
const showContactsDropdown = ref(false);
const contactableInboxesList = computed(() => {
return buildContactableInboxesList(props.selectedContact?.contactInboxes);
});
const validationRules = computed(() => ({
selectedContact: { required },
}));
const v$ = useVuelidate(validationRules, {
selectedContact: computed(() => props.selectedContact),
});
const validationStates = computed(() => ({
isContactInvalid:
v$.value.selectedContact.$dirty && v$.value.selectedContact.$invalid,
}));
const handleContactSearch = value => {
showContactsDropdown.value = true;
emit('searchContacts', {
keys: ['email'],
query: value,
});
};
const setSelectedContact = async ({ value, action, ...rest }) => {
v$.value.$reset();
emit('updateSelectedContact', { value, action, ...rest });
showContactsDropdown.value = false;
};
const clearSelectedContact = () => {
emit('clearSelectedContact');
state.attachedFiles = [];
};
const handleDropdownUpdate = (type, value) => {
showContactsDropdown.value = value;
};
const onClickInsertEmoji = emoji => {
state.message += emoji;
};
const handleAddSignature = signature => {
state.message = appendSignature(state.message, signature);
};
const handleRemoveSignature = signature => {
state.message = removeSignature(state.message, signature);
};
const handleAttachFile = files => {
state.attachedFiles = files;
};
const clearForm = () => {
Object.assign(state, {
message: '',
attachedFiles: [],
});
v$.value.$reset();
};
const handleSendMessage = async () => {
const isValid = await v$.value.$validate();
if (!isValid) return;
try {
const success = await emit('forwardMessage', { state });
if (success) {
clearForm();
}
} catch (error) {
// Form will not be cleared if conversation creation fails
}
};
</script>
<template>
<div
class="w-[42rem] max-h-[31.25rem] divide-y divide-n-strong transition-all duration-300 ease-in-out top-full justify-between flex flex-col border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl overflow-hidden"
>
<div
class="relative flex-1 rounded-t-xl px-4 py-3 overflow-y-visible bg-n-alpha-3"
>
<div class="flex items-baseline w-full gap-3 min-h-7">
<label class="text-sm font-medium text-n-slate-11 whitespace-nowrap">
{{ t('FORWARD_MESSAGE_FORM.FROM') }}
</label>
<div
class="flex items-center gap-1.5 rounded-md bg-n-alpha-2 px-3 min-h-7 min-w-0"
>
<span class="text-sm truncate text-n-slate-12">
{{ fromEmail }}
</span>
</div>
</div>
</div>
<ContactSelector
class="bg-n-alpha-3"
:contacts="contacts"
:selected-contact="selectedContact"
:show-contacts-dropdown="showContactsDropdown"
:is-loading="isLoading"
:is-creating-contact="isCreatingContact"
:contactable-inboxes-list="contactableInboxesList"
:show-inboxes-dropdown="false"
:has-errors="validationStates.isContactInvalid"
@search-contacts="handleContactSearch"
@set-selected-contact="setSelectedContact"
@clear-selected-contact="clearSelectedContact"
@update-dropdown="handleDropdownUpdate"
/>
<div class="overflow-y-scroll">
<EmailMessageEditor
v-model="state.message"
class="bg-n-alpha-3"
:content="content"
:is-plain-email="isPlainEmail"
:has-quoted-message="hasQuotedMessage"
:full-html="fullHtml"
:unquoted-html="unquotedHtml"
:text-to-show="textToShow"
/>
<section
v-if="Array.isArray(attachments) && attachments.length"
class="px-4 pb-4 pt-2 !border-t-0 space-y-2 bg-n-alpha-3"
>
<AttachmentChips
:attachments="attachments"
class="gap-1 !justify-start"
/>
</section>
</div>
<AttachmentPreviews
v-if="state.attachedFiles.length > 0"
:attachments="state.attachedFiles"
class="bg-n-alpha-3"
@update:attachments="state.attachedFiles = $event"
/>
<ActionButtons
class="bg-n-alpha-3 sticky bottom-0 backdrop-blur-[100px]"
:attached-files="state.attachedFiles"
is-email-or-web-widget-inbox
channel-type="Channel::Email"
:is-loading="false"
:disable-send-button="false"
has-selected-inbox
:has-no-inbox="false"
:is-dropdown-active="showContactsDropdown"
:message-signature="messageSignature"
@insert-emoji="onClickInsertEmoji"
@add-signature="handleAddSignature"
@remove-signature="handleRemoveSignature"
@attach-file="handleAttachFile"
@discard="$emit('discard')"
@send-message="handleSendMessage"
/>
</div>
</template>
@@ -100,7 +100,7 @@ const MessageControl = Symbol('MessageControl');
* @property {import('vue').ComputedRef<MessageVariant>} variant - The visual variant of the message
* @property {import('vue').ComputedRef<boolean>} isBotOrAgentMessage - Does the message belong to the current user
* @property {import('vue').ComputedRef<boolean>} isPrivate - Proxy computed value for private
* @property {import('vue').ComputedRef<boolean>} shouldGroupWithNext - Should group with the next message or not, it is differnt from groupWithNext, this has a bypass for a failed message
* @property {import('vue').ComputedRef<boolean>} shouldGroupWithNext - Should group with the next message or not, it is different from groupWithNext, this has a bypass for a failed message
*/
/**
@@ -72,6 +72,10 @@ const formatType = computed(() => {
return format ? format.charAt(0) + format.slice(1).toLowerCase() : '';
});
const isDocumentTemplate = computed(() => {
return headerComponent.value?.format?.toLowerCase() === 'document';
});
const hasVariables = computed(() => {
return bodyText.value?.match(/{{([^}]+)}}/g) !== null;
});
@@ -173,6 +177,7 @@ defineExpose({
processedParams,
hasVariables,
hasMediaHeader,
isDocumentTemplate,
headerComponent,
renderedTemplate,
v$,
@@ -231,17 +236,13 @@ defineExpose({
@update:model-value="updateMediaUrl"
/>
</div>
<div
v-if="headerComponent?.format === 'DOCUMENT'"
class="flex items-center mb-2.5"
>
<div v-if="isDocumentTemplate" class="flex items-center mb-2.5">
<Input
:model-value="processedParams.header?.media_name || ''"
type="text"
class="flex-1"
:placeholder="
t('WHATSAPP_TEMPLATES.PARSER.MEDIA_NAME_PLACEHOLDER') ||
'Enter custom filename (optional)'
t('WHATSAPP_TEMPLATES.PARSER.DOCUMENT_NAME_PLACEHOLDER')
"
@update:model-value="updateMediaName"
/>
@@ -0,0 +1,328 @@
<script setup>
import axios from 'axios';
import { ref, computed, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import { handleOtpPaste } from 'shared/helpers/clipboard';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import { useAccount } from 'dashboard/composables/useAccount';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import FormInput from 'v3/components/Form/Input.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const props = defineProps({
mfaToken: {
type: String,
required: true,
},
});
const emit = defineEmits(['verified', 'cancel']);
const { t } = useI18n();
const { isOnChatwootCloud } = useAccount();
const OTP = 'otp';
const BACKUP = 'backup';
// State
const verificationMethod = ref(OTP);
const otpDigits = ref(['', '', '', '', '', '']);
const backupCode = ref('');
const isVerifying = ref(false);
const errorMessage = ref('');
const helpModalRef = ref(null);
const otpInputRefs = ref([]);
// Computed
const otpCode = computed(() => otpDigits.value.join(''));
const canSubmit = computed(() =>
verificationMethod.value === OTP
? otpCode.value.length === 6
: backupCode.value.length === 8
);
const contactDescKey = computed(() =>
isOnChatwootCloud.value ? 'CONTACT_DESC_CLOUD' : 'CONTACT_DESC_SELF_HOSTED'
);
const focusInput = i => otpInputRefs.value[i]?.focus();
// Verification
const handleVerification = async () => {
if (!canSubmit.value || isVerifying.value) return;
isVerifying.value = true;
errorMessage.value = '';
try {
const payload = {
mfa_token: props.mfaToken,
};
if (verificationMethod.value === OTP) {
payload.otp_code = otpCode.value;
} else {
payload.backup_code = backupCode.value;
}
const response = await axios.post('/auth/sign_in', payload);
// Set auth credentials and redirect
if (response.data && response.headers) {
// Store auth credentials in cookies
const authData = {
'access-token': response.headers['access-token'],
'token-type': response.headers['token-type'],
client: response.headers.client,
expiry: response.headers.expiry,
uid: response.headers.uid,
};
// Store in cookies for auth
document.cookie = `cw_d_session_info=${encodeURIComponent(JSON.stringify(authData))}; path=/; SameSite=Lax`;
// Redirect to dashboard
window.location.href = '/app/';
} else {
emit('verified', response.data);
}
} catch (error) {
errorMessage.value =
parseAPIErrorResponse(error) || t('MFA_VERIFICATION.VERIFICATION_FAILED');
// Clear inputs on error
if (verificationMethod.value === OTP) {
otpDigits.value.fill('');
await nextTick();
focusInput(0);
} else {
backupCode.value = '';
}
} finally {
isVerifying.value = false;
}
};
// OTP Input Handling
const handleOtpInput = async i => {
const v = otpDigits.value[i];
// Only allow numbers
if (!/^\d*$/.test(v)) {
otpDigits.value[i] = '';
return;
}
// Move to next input if value entered
if (v && i < 5) {
await nextTick();
focusInput(i + 1);
}
// Auto-submit if all digits entered
if (otpCode.value.length === 6) {
handleVerification();
}
};
const handleBackspace = (e, i) => {
if (!otpDigits.value[i] && i > 0) {
e.preventDefault();
focusInput(i - 1);
otpDigits.value[i - 1] = '';
}
};
const handleOtpCodePaste = e => {
e.preventDefault();
const code = handleOtpPaste(e, 6);
if (code) {
otpDigits.value = code.split('');
handleVerification();
}
};
// Alternative Actions
const handleTryAnotherMethod = () => {
// Toggle between methods
verificationMethod.value = verificationMethod.value === OTP ? BACKUP : OTP;
otpDigits.value.fill('');
backupCode.value = '';
errorMessage.value = '';
};
</script>
<template>
<div class="w-full max-w-md mx-auto">
<div
class="bg-white shadow sm:mx-auto sm:w-full sm:max-w-lg dark:bg-n-solid-2 p-11 sm:shadow-lg sm:rounded-lg"
>
<!-- Header -->
<div class="text-center mb-6">
<div
class="inline-flex items-center justify-center size-14 bg-n-solid-1 outline outline-n-weak rounded-full mb-4"
>
<Icon icon="i-lucide-lock-keyhole" class="size-6 text-n-slate-10" />
</div>
<h2 class="text-2xl font-semibold text-n-slate-12">
{{ $t('MFA_VERIFICATION.TITLE') }}
</h2>
<p class="text-sm text-n-slate-11 mt-2">
{{ $t('MFA_VERIFICATION.DESCRIPTION') }}
</p>
</div>
<!-- Tab Selection -->
<div class="flex rounded-lg bg-n-alpha-black2 p-1 mb-6">
<button
v-for="method in [OTP, BACKUP]"
:key="method"
class="flex-1 py-2 px-4 text-sm font-medium rounded-md transition-colors"
:class="
verificationMethod === method
? 'bg-n-solid-active text-n-slate-12 shadow-sm'
: 'text-n-slate-12'
"
@click="verificationMethod = method"
>
{{
$t(
`MFA_VERIFICATION.${method === OTP ? 'AUTHENTICATOR_APP' : 'BACKUP_CODE'}`
)
}}
</button>
</div>
<!-- Verification Form -->
<form class="space-y-4" @submit.prevent="handleVerification">
<!-- OTP Code Input -->
<div v-if="verificationMethod === OTP">
<label class="block text-sm font-medium text-n-slate-12 mb-2">
{{ $t('MFA_VERIFICATION.ENTER_OTP_CODE') }}
</label>
<div class="flex justify-between gap-2">
<input
v-for="(_, i) in otpDigits"
:key="i"
ref="otpInputRefs"
v-model="otpDigits[i]"
type="text"
maxlength="1"
pattern="[0-9]"
inputmode="numeric"
class="w-12 h-12 text-center text-lg font-semibold border-2 border-n-weak hover:border-n-strong rounded-lg focus:border-n-brand bg-n-alpha-black2 text-n-slate-12 placeholder:text-n-slate-10"
@input="handleOtpInput(i)"
@keydown.left.prevent="focusInput(i - 1)"
@keydown.right.prevent="focusInput(i + 1)"
@keydown.backspace="handleBackspace($event, i)"
@paste="handleOtpCodePaste"
/>
</div>
</div>
<!-- Backup Code Input -->
<div v-if="verificationMethod === BACKUP">
<FormInput
v-model="backupCode"
name="backup_code"
type="text"
data-testid="backup_code_input"
:tabindex="1"
required
:label="$t('MFA_VERIFICATION.ENTER_BACKUP_CODE')"
:placeholder="
$t('MFA_VERIFICATION.BACKUP_CODE_PLACEHOLDER') || '000000'
"
@keyup.enter="handleVerification"
/>
</div>
<!-- Error Message -->
<div
v-if="errorMessage"
class="p-3 bg-n-ruby-3 outline outline-n-ruby-5 outline-1 rounded-lg"
>
<p class="text-sm text-n-ruby-9">{{ errorMessage }}</p>
</div>
<!-- Submit Button -->
<NextButton
lg
type="submit"
data-testid="submit_button"
class="w-full"
:tabindex="2"
:label="$t('MFA_VERIFICATION.VERIFY_BUTTON')"
:disabled="!canSubmit || isVerifying"
:is-loading="isVerifying"
/>
<!-- Alternative Actions -->
<div class="text-center flex items-center flex-col gap-2 pt-4">
<NextButton
sm
link
type="button"
class="w-full hover:!no-underline"
:tabindex="2"
:label="$t('MFA_VERIFICATION.TRY_ANOTHER_METHOD')"
@click="handleTryAnotherMethod"
/>
<NextButton
sm
slate
link
type="button"
class="w-full hover:!no-underline"
:tabindex="3"
:label="$t('MFA_VERIFICATION.CANCEL_LOGIN')"
@click="() => emit('cancel')"
/>
</div>
</form>
</div>
<!-- Help Text -->
<div class="mt-6 text-center">
<p class="text-sm text-n-slate-11">
{{ $t('MFA_VERIFICATION.HELP_TEXT') }}
</p>
<NextButton
sm
link
type="button"
class="w-full hover:!no-underline"
:tabindex="4"
:label="$t('MFA_VERIFICATION.LEARN_MORE')"
@click="helpModalRef?.open()"
/>
</div>
<!-- Help Modal -->
<Dialog
ref="helpModalRef"
:title="$t('MFA_VERIFICATION.HELP_MODAL.TITLE')"
:show-confirm-button="false"
class="[&>dialog>div]:bg-n-alpha-3 [&>dialog>div]:rounded-lg"
@confirm="helpModalRef?.close()"
>
<div class="space-y-4 text-sm text-n-slate-11">
<div v-for="section in ['AUTHENTICATOR', 'BACKUP']" :key="section">
<h4 class="font-medium text-n-slate-12 mb-2">
{{ $t(`MFA_VERIFICATION.HELP_MODAL.${section}_TITLE`) }}
</h4>
<p>{{ $t(`MFA_VERIFICATION.HELP_MODAL.${section}_DESC`) }}</p>
</div>
<div>
<h4 class="font-medium text-n-slate-12 mb-2">
{{ $t('MFA_VERIFICATION.HELP_MODAL.CONTACT_TITLE') }}
</h4>
<p>{{ $t(`MFA_VERIFICATION.HELP_MODAL.${contactDescKey}`) }}</p>
</div>
</div>
</Dialog>
</div>
</template>
@@ -24,7 +24,7 @@ const menuRef = useTemplateRef('menuRef');
const scrollLockElement = computed(() => {
if (!elementToLock?.value) return null;
return elementToLock.value?.$el;
return elementToLock.value.$el;
});
const isLocked = useScrollLock(scrollLockElement);
@@ -11,6 +11,7 @@ import ReplyBox from './ReplyBox.vue';
import MessageList from 'next/message/MessageList.vue';
import ConversationLabelSuggestion from './conversation/LabelSuggestion.vue';
import Banner from 'dashboard/components/ui/Banner.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
// stores and apis
@@ -44,6 +45,7 @@ export default {
Banner,
ConversationLabelSuggestion,
Spinner,
Icon,
},
mixins: [inboxMixin],
setup() {
@@ -495,6 +497,16 @@ export default {
</span>
</li>
</template>
<template #forwardedMessageAddress="{ address }">
<li class="flex items-center gap-1 !mt-4 !mb-2.5 ltr:pl-9 rtl:pr-9 h-5">
<Icon icon="i-lucide-forward" class="text-n-amber-10 size-4" />
<span class="text-n-amber-10 text-xs font-medium leading-[20px]">
{{
$t('CONVERSATION.FORWARDED_TO', { address: address?.join(', ') })
}}
</span>
</li>
</template>
<template #after>
<ConversationLabelSuggestion
v-if="shouldShowLabelSuggestions"
@@ -104,6 +104,7 @@ export default function useAutomationValues() {
contacts: contacts.value,
customAttributes: getters['attributes/getAttributes'].value,
inboxes: inboxes.value,
labels: labels.value,
statusFilterOptions: statusFilterOptions.value,
priorityOptions: priorityOptions.value,
messageTypeOptions: messageTypeOptions.value,
@@ -124,6 +124,7 @@ export const getConditionOptions = ({
customAttributes,
inboxes,
languages,
labels,
statusFilterOptions,
teams,
type,
@@ -150,6 +151,7 @@ export const getConditionOptions = ({
country_code: countries,
message_type: messageTypeOptions,
priority: priorityOptions,
labels: generateConditionOptions(labels, 'title'),
};
return conditionFilterMaps[type];
@@ -218,6 +218,7 @@ describe('templateHelper', () => {
expect(result.header).toEqual({
media_url: '',
media_type: 'document',
media_name: '',
});
expect(result.body).toEqual({
1: '',
@@ -51,7 +51,9 @@ export const buildTemplateParameters = (template, hasMediaHeaderValue) => {
if (!allVariables.header) allVariables.header = {};
allVariables.header.media_url = '';
allVariables.header.media_type = headerComponent.format.toLowerCase();
if (headerComponent.format === 'DOCUMENT') {
// For document templates, include media_name field for filename support
if (headerComponent.format.toLowerCase() === 'document') {
allVariables.header.media_name = '';
}
}
@@ -177,7 +177,8 @@
"REFERER_LINK": "Referrer Link",
"ASSIGNEE_NAME": "Assignee",
"TEAM_NAME": "Team",
"PRIORITY": "Priority"
"PRIORITY": "Priority",
"LABELS": "Labels"
}
}
}
@@ -67,6 +67,7 @@
"RATING_TITLE": "Rating",
"FEEDBACK_TITLE": "Feedback",
"REPLY_MESSAGE_NOT_FOUND": "Message not available",
"FORWARDED_TO": "forwarded to {address}",
"CARD": {
"SHOW_LABELS": "Show labels",
"HIDE_LABELS": "Hide labels"
@@ -258,6 +259,7 @@
"TRANSLATE": "Translate",
"COPY_PERMALINK": "Copy link to the message",
"LINK_COPIED": "Message URL copied to the clipboard",
"FORWARD_EMAIL": "Forward email",
"DELETE_CONFIRMATION": {
"TITLE": "Are you sure you want to delete this message?",
"MESSAGE": "You cannot undo this action",
@@ -270,6 +272,20 @@
"COPILOT": "Copilot"
}
},
"FORWARD_MESSAGE_FORM": {
"FROM": "From :",
"EMAIL_EDITOR_PLACEHOLDER": "Write your message here...",
"FORWARDED_MESSAGE": "---------- Forwarded message ----------",
"SHOW_QUOTED_TEXT": "Show quoted text",
"HIDE_QUOTED_TEXT": "Hide quoted text",
"CONTACT_SEARCH": {
"ERROR_MESSAGE": "We couldn’t complete the search. Please try again."
},
"FORWARD_MESSAGE": {
"ERROR_MESSAGE": "We couldn’t able to forward the message. Please try again.",
"SUCCESS_MESSAGE": "The message was forwarded successfully!"
}
},
"EMAIL_TRANSCRIPT": {
"TITLE": "Send conversation transcript",
"DESC": "Send a copy of the conversation transcript to the specified email address",
@@ -36,6 +36,7 @@ import sla from './sla.json';
import teamsSettings from './teamsSettings.json';
import whatsappTemplates from './whatsappTemplates.json';
import contentTemplates from './contentTemplates.json';
import mfa from './mfa.json';
export default {
...advancedFilters,
@@ -76,4 +77,5 @@ export default {
...teamsSettings,
...whatsappTemplates,
...contentTemplates,
...mfa,
};
@@ -759,6 +759,7 @@
"SELECTED": "{count} selected",
"SELECT_ALL": "Select all ({count})",
"UNSELECT_ALL": "Unselect all ({count})",
"SEARCH_PLACEHOLDER": "Search FAQs...",
"BULK_APPROVE_BUTTON": "Approve",
"BULK_DELETE_BUTTON": "Delete",
"BULK_APPROVE": {
@@ -0,0 +1,106 @@
{
"MFA_SETTINGS": {
"TITLE": "Two-Factor Authentication",
"SUBTITLE": "Secure your account with TOTP-based authentication",
"DESCRIPTION": "Add an extra layer of security to your account using a time-based one-time password (TOTP)",
"STATUS_TITLE": "Authentication Status",
"STATUS_DESCRIPTION": "Manage your two-factor authentication settings and backup recovery codes",
"ENABLED": "Enabled",
"DISABLED": "Disabled",
"STATUS_ENABLED": "Two-factor authentication is active",
"STATUS_ENABLED_DESC": "Your account is protected with an additional layer of security",
"ENABLE_BUTTON": "Enable Two-Factor Authentication",
"ENHANCE_SECURITY": "Enhance Your Account Security",
"ENHANCE_SECURITY_DESC": "Two-factor authentication adds an extra layer of security by requiring a verification code from your authenticator app in addition to your password.",
"SETUP": {
"STEP_NUMBER_1": "1",
"STEP_NUMBER_2": "2",
"STEP1_TITLE": "Scan QR Code with Your Authenticator App",
"STEP1_DESCRIPTION": "Use Google Authenticator, Authy, or any TOTP-compatible app",
"LOADING_QR": "Loading...",
"MANUAL_ENTRY": "Can't scan? Enter code manually",
"SECRET_KEY": "Secret Key",
"COPY": "Copy",
"ENTER_CODE": "Enter the 6-digit code from your authenticator app",
"ENTER_CODE_PLACEHOLDER": "000000",
"VERIFY_BUTTON": "Verify & Continue",
"CANCEL": "Cancel",
"ERROR_STARTING": "MFA not enabled. Please contact administrator.",
"INVALID_CODE": "Invalid verification code",
"SECRET_COPIED": "Secret key copied to clipboard",
"SUCCESS": "Two-factor authentication has been enabled successfully"
},
"BACKUP": {
"TITLE": "Save Your Backup Codes",
"DESCRIPTION": "Keep these codes safe. Each can be used once if you lose access to your authenticator",
"IMPORTANT": "Important:",
"IMPORTANT_NOTE": " Save these codes in a secure location. You won't be able to see them again.",
"DOWNLOAD": "Download",
"COPY_ALL": "Copy All",
"CONFIRM": "I have saved my backup codes in a secure location and understand that I won't be able to see them again",
"COMPLETE_SETUP": "Complete Setup",
"CODES_COPIED": "Backup codes copied to clipboard"
},
"MANAGEMENT": {
"BACKUP_CODES": "Backup Codes",
"BACKUP_CODES_DESC": "Generate new codes if you've lost or used your existing ones",
"REGENERATE": "Regenerate Backup Codes",
"DISABLE_MFA": "Disable 2FA",
"DISABLE_MFA_DESC": "Remove two-factor authentication from your account",
"DISABLE_BUTTON": "Disable Two-Factor Authentication"
},
"DISABLE": {
"TITLE": "Disable Two-Factor Authentication",
"DESCRIPTION": "You'll need to enter your password and a verification code to disable two-factor authentication.",
"PASSWORD": "Password",
"OTP_CODE": "Verification Code",
"OTP_CODE_PLACEHOLDER": "000000",
"CONFIRM": "Disable 2FA",
"CANCEL": "Cancel",
"SUCCESS": "Two-factor authentication has been disabled",
"ERROR": "Failed to disable MFA. Please check your credentials."
},
"REGENERATE": {
"TITLE": "Regenerate Backup Codes",
"DESCRIPTION": "This will invalidate your existing backup codes and generate new ones. Enter your verification code to continue.",
"OTP_CODE": "Verification Code",
"OTP_CODE_PLACEHOLDER": "000000",
"CONFIRM": "Generate New Codes",
"CANCEL": "Cancel",
"NEW_CODES_TITLE": "New Backup Codes Generated",
"NEW_CODES_DESC": "Your old backup codes have been invalidated. Save these new codes in a secure location.",
"CODES_IMPORTANT": "Important:",
"CODES_IMPORTANT_NOTE": " Each code can only be used once. Save them before closing this window.",
"DOWNLOAD_CODES": "Download Codes",
"COPY_ALL_CODES": "Copy All Codes",
"CODES_SAVED": "I've Saved My Codes",
"SUCCESS": "New backup codes have been generated",
"ERROR": "Failed to regenerate backup codes"
}
},
"MFA_VERIFICATION": {
"TITLE": "Two-Factor Authentication",
"DESCRIPTION": "Enter your verification code to continue",
"AUTHENTICATOR_APP": "Authenticator App",
"BACKUP_CODE": "Backup Code",
"ENTER_OTP_CODE": "Enter 6-digit code from your authenticator app",
"ENTER_BACKUP_CODE": "Enter one of your backup codes",
"BACKUP_CODE_PLACEHOLDER": "000000",
"VERIFY_BUTTON": "Verify",
"TRY_ANOTHER_METHOD": "Try another verification method",
"CANCEL_LOGIN": "Cancel and return to login",
"HELP_TEXT": "Having trouble signing in?",
"LEARN_MORE": "Learn more about 2FA",
"HELP_MODAL": {
"TITLE": "Two-Factor Authentication Help",
"AUTHENTICATOR_TITLE": "Using an Authenticator App",
"AUTHENTICATOR_DESC": "Open your authenticator app (Google Authenticator, Authy, etc.) and enter the 6-digit code shown for your account.",
"BACKUP_TITLE": "Using a Backup Code",
"BACKUP_DESC": "If you don't have access to your authenticator app, you can use one of the backup codes you saved when setting up 2FA. Each code can only be used once.",
"CONTACT_TITLE": "Need More Help?",
"CONTACT_DESC_CLOUD": "If you've lost access to both your authenticator app and backup codes, please reach out to Chatwoot support for assistance.",
"CONTACT_DESC_SELF_HOSTED": "If you've lost access to both your authenticator app and backup codes, please contact your administrator for assistance."
},
"VERIFICATION_FAILED": "Verification failed. Please try again."
}
}
@@ -80,6 +80,11 @@
"NOTE": "Updating your password would reset your logins in multiple devices.",
"BTN_TEXT": "Change password"
},
"SECURITY_SECTION": {
"TITLE": "Security",
"NOTE": "Manage additional security features for your account.",
"MFA_BUTTON": "Manage Two-Factor Authentication"
},
"ACCESS_TOKEN": {
"TITLE": "Access Token",
"NOTE": "This token can be used if you are building an API based integration",
@@ -40,7 +40,7 @@
"BUTTON_LABEL": "Button {index}",
"COUPON_CODE": "Enter coupon code (max 15 chars)",
"MEDIA_URL_LABEL": "Enter {type} URL",
"MEDIA_NAME_PLACEHOLDER": "Enter custom filename",
"DOCUMENT_NAME_PLACEHOLDER": "Enter document filename (e.g., Invoice_2025.pdf)",
"BUTTON_PARAMETER": "Enter button parameter"
}
}
@@ -44,7 +44,7 @@ export default {
default: false,
},
},
emits: ['open', 'close', 'replyTo'],
emits: ['open', 'close', 'replyTo', 'forwardEmail'],
setup() {
const { getPlainText } = useMessageFormatter();
@@ -116,6 +116,13 @@ export default {
handleClose(e) {
this.$emit('close', e);
},
openForwardModal() {
this.handleClose();
this.$emit('forwardEmail', {
x: this.contextMenuPosition.x,
y: this.contextMenuPosition.y,
});
},
handleTranslate() {
const { locale } = this.getAccount(this.currentAccountId);
this.$store.dispatch('translateMessage', {
@@ -240,6 +247,15 @@ export default {
variant="icon"
@click.stop="showCannedResponseModal"
/>
<MenuItem
v-if="enabledOptions['forwardEmail']"
:option="{
icon: 'forward',
label: $t('CONVERSATION.CONTEXT_MENU.FORWARD_EMAIL'),
}"
variant="icon"
@click.stop="openForwardModal"
/>
<hr v-if="enabledOptions['delete']" />
<MenuItem
v-if="enabledOptions['delete']"
@@ -6,10 +6,12 @@ import { useI18n } from 'vue-i18n';
import { OnClickOutside } from '@vueuse/components';
import { useRouter } from 'vue-router';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { debounce } from '@chatwoot/utils';
import Button from 'dashboard/components-next/button/Button.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
import BulkDeleteDialog from 'dashboard/components-next/captain/pageComponents/BulkDeleteDialog.vue';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
@@ -36,6 +38,7 @@ const bulkDeleteDialog = ref(null);
const selectedStatus = ref('all');
const selectedAssistant = ref('all');
const dialogType = ref('');
const searchQuery = ref('');
const { t } = useI18n();
const createDialog = ref(null);
@@ -138,6 +141,9 @@ const fetchResponses = (page = 1) => {
if (selectedAssistant.value !== 'all') {
filterParams.assistantId = selectedAssistant.value;
}
if (searchQuery.value) {
filterParams.search = searchQuery.value;
}
store.dispatch('captainResponses/get', filterParams);
};
@@ -250,6 +256,10 @@ const handleAssistantFilterChange = assistant => {
fetchResponses();
};
const debouncedSearch = debounce(async () => {
fetchResponses();
}, 500);
onMounted(() => {
store.dispatch('captainAssistants/get');
fetchResponses();
@@ -292,34 +302,47 @@ onMounted(() => {
<template #controls>
<div
v-if="shouldShowDropdown"
class="mb-4 -mt-3 flex justify-between items-center w-fit py-1"
class="mb-4 -mt-3 flex justify-between items-center py-1"
:class="{
'ltr:pl-3 rtl:pr-3 ltr:pr-1 rtl:pl-1 rounded-lg outline outline-1 outline-n-weak bg-n-solid-3':
'ltr:pl-3 rtl:pr-3 ltr:pr-1 rtl:pl-1 rounded-lg outline outline-1 outline-n-weak bg-n-solid-3 w-fit':
bulkSelectionState.hasSelected,
}"
>
<div v-if="!bulkSelectionState.hasSelected" class="flex gap-3">
<OnClickOutside @trigger="isStatusFilterOpen = false">
<Button
:label="selectedStatusLabel"
icon="i-lucide-chevron-down"
size="sm"
color="slate"
trailing-icon
class="max-w-48"
@click="isStatusFilterOpen = !isStatusFilterOpen"
/>
<div
v-if="!bulkSelectionState.hasSelected"
class="flex gap-3 justify-between w-full items-center"
>
<div class="flex gap-3">
<OnClickOutside @trigger="isStatusFilterOpen = false">
<Button
:label="selectedStatusLabel"
icon="i-lucide-chevron-down"
size="sm"
color="slate"
trailing-icon
class="max-w-48"
@click="isStatusFilterOpen = !isStatusFilterOpen"
/>
<DropdownMenu
v-if="isStatusFilterOpen"
:menu-items="statusOptions"
class="mt-2"
@action="handleStatusFilterChange"
<DropdownMenu
v-if="isStatusFilterOpen"
:menu-items="statusOptions"
class="mt-2"
@action="handleStatusFilterChange"
/>
</OnClickOutside>
<AssistantSelector
:assistant-id="selectedAssistant"
@update="handleAssistantFilterChange"
/>
</OnClickOutside>
<AssistantSelector
:assistant-id="selectedAssistant"
@update="handleAssistantFilterChange"
</div>
<Input
v-model="searchQuery"
:placeholder="$t('CAPTAIN.RESPONSES.SEARCH_PLACEHOLDER')"
class="w-64"
size="sm"
autofocus
@input="debouncedSearch"
/>
</div>
@@ -68,6 +68,12 @@ export const AUTOMATIONS = {
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_6,
},
{
key: 'labels',
name: 'LABELS',
inputType: 'multi_select',
filterOperators: OPERATOR_TYPES_3,
},
],
actions: [
{
@@ -186,6 +192,12 @@ export const AUTOMATIONS = {
inputType: 'multi_select',
filterOperators: OPERATOR_TYPES_1,
},
{
key: 'labels',
name: 'LABELS',
inputType: 'multi_select',
filterOperators: OPERATOR_TYPES_3,
},
],
actions: [
{
@@ -308,6 +320,12 @@ export const AUTOMATIONS = {
inputType: 'multi_select',
filterOperators: OPERATOR_TYPES_1,
},
{
key: 'labels',
name: 'LABELS',
inputType: 'multi_select',
filterOperators: OPERATOR_TYPES_3,
},
],
actions: [
{
@@ -424,6 +442,12 @@ export const AUTOMATIONS = {
inputType: 'multi_select',
filterOperators: OPERATOR_TYPES_1,
},
{
key: 'labels',
name: 'LABELS',
inputType: 'multi_select',
filterOperators: OPERATOR_TYPES_3,
},
],
actions: [
{
@@ -7,6 +7,7 @@ import { useBranding } from 'shared/composables/useBranding';
import { clearCookiesOnLogout } from 'dashboard/store/utils/api.js';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import { parseBoolean } from '@chatwoot/utils';
import UserProfilePicture from './UserProfilePicture.vue';
import UserBasicDetails from './UserBasicDetails.vue';
import MessageSignature from './MessageSignature.vue';
@@ -18,6 +19,7 @@ import NotificationPreferences from './NotificationPreferences.vue';
import AudioNotifications from './AudioNotifications.vue';
import FormSection from 'dashboard/components/FormSection.vue';
import AccessToken from './AccessToken.vue';
import MfaSettingsCard from './MfaSettingsCard.vue';
import Policy from 'dashboard/components/policy.vue';
import {
ROLES,
@@ -38,6 +40,7 @@ export default {
NotificationPreferences,
AudioNotifications,
AccessToken,
MfaSettingsCard,
},
setup() {
const { isEditorHotKeyEnabled, updateUISettings } = useUISettings();
@@ -95,6 +98,9 @@ export default {
currentUserId: 'getCurrentUserID',
globalConfig: 'globalConfig/get',
}),
isMfaEnabled() {
return parseBoolean(window.chatwootConfig?.isMfaEnabled);
},
},
mounted() {
if (this.currentUserId) {
@@ -283,6 +289,13 @@ export default {
>
<ChangePassword />
</FormSection>
<FormSection
v-if="isMfaEnabled"
:title="$t('PROFILE_SETTINGS.FORM.SECURITY_SECTION.TITLE')"
:description="$t('PROFILE_SETTINGS.FORM.SECURITY_SECTION.NOTE')"
>
<MfaSettingsCard />
</FormSection>
<Policy :permissions="audioNotificationPermissions">
<FormSection
:title="$t('PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.TITLE')"
@@ -0,0 +1,248 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { useAlert } from 'dashboard/composables';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const props = defineProps({
mfaEnabled: {
type: Boolean,
required: true,
},
backupCodes: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['disableMfa', 'regenerateBackupCodes']);
const { t } = useI18n();
// Dialog refs
const disableDialogRef = ref(null);
const regenerateDialogRef = ref(null);
const backupCodesDialogRef = ref(null);
// Form values
const disablePassword = ref('');
const disableOtpCode = ref('');
const regenerateOtpCode = ref('');
// Utility functions
const copyBackupCodes = async () => {
const codesText = props.backupCodes.join('\n');
await copyTextToClipboard(codesText);
useAlert(t('MFA_SETTINGS.BACKUP.CODES_COPIED'));
};
const downloadBackupCodes = () => {
const codesText = `Chatwoot Two-Factor Authentication Backup Codes\n\n${props.backupCodes.join('\n')}\n\nKeep these codes in a safe place.`;
const blob = new Blob([codesText], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'chatwoot-backup-codes.txt';
a.click();
URL.revokeObjectURL(url);
};
const handleDisableMfa = async () => {
emit('disableMfa', {
password: disablePassword.value,
otpCode: disableOtpCode.value,
});
};
const handleRegenerateBackupCodes = async () => {
emit('regenerateBackupCodes', {
otpCode: regenerateOtpCode.value,
});
};
// Methods exposed for parent component
const resetDisableForm = () => {
disablePassword.value = '';
disableOtpCode.value = '';
disableDialogRef.value?.close();
};
const resetRegenerateForm = () => {
regenerateOtpCode.value = '';
regenerateDialogRef.value?.close();
};
const showBackupCodesDialog = () => {
backupCodesDialogRef.value?.open();
};
defineExpose({
resetDisableForm,
resetRegenerateForm,
showBackupCodesDialog,
});
</script>
<template>
<div v-if="mfaEnabled">
<!-- Actions Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Regenerate Backup Codes -->
<div class="bg-n-solid-1 rounded-xl outline-1 outline-n-weak outline p-5">
<div class="flex-1 flex flex-col gap-2">
<div class="flex items-center gap-2">
<Icon
icon="i-lucide-key"
class="size-4 flex-shrink-0 text-n-slate-11"
/>
<h4 class="font-medium text-n-slate-12">
{{ $t('MFA_SETTINGS.MANAGEMENT.BACKUP_CODES') }}
</h4>
</div>
<p class="text-sm text-n-slate-11">
{{ $t('MFA_SETTINGS.MANAGEMENT.BACKUP_CODES_DESC') }}
</p>
<Button
faded
slate
:label="$t('MFA_SETTINGS.MANAGEMENT.REGENERATE')"
@click="regenerateDialogRef?.open()"
/>
</div>
</div>
<!-- Disable MFA -->
<div class="bg-n-solid-1 rounded-xl outline-1 outline-n-weak outline p-5">
<div class="flex-1 flex flex-col gap-2">
<div class="flex items-center gap-2">
<Icon
icon="i-lucide-lock-keyhole-open"
class="size-4 flex-shrink-0 text-n-slate-11"
/>
<h4 class="font-medium text-n-slate-12">
{{ $t('MFA_SETTINGS.MANAGEMENT.DISABLE_MFA') }}
</h4>
</div>
<p class="text-sm text-n-slate-11">
{{ $t('MFA_SETTINGS.MANAGEMENT.DISABLE_MFA_DESC') }}
</p>
<Button
faded
ruby
:label="$t('MFA_SETTINGS.MANAGEMENT.DISABLE_BUTTON')"
@click="disableDialogRef?.open()"
/>
</div>
</div>
</div>
<!-- Disable MFA Dialog -->
<Dialog
ref="disableDialogRef"
type="alert"
:title="$t('MFA_SETTINGS.DISABLE.TITLE')"
:description="$t('MFA_SETTINGS.DISABLE.DESCRIPTION')"
:confirm-button-label="$t('MFA_SETTINGS.DISABLE.CONFIRM')"
:cancel-button-label="$t('MFA_SETTINGS.DISABLE.CANCEL')"
@confirm="handleDisableMfa"
>
<div class="space-y-4">
<Input
v-model="disablePassword"
type="password"
:label="$t('MFA_SETTINGS.DISABLE.PASSWORD')"
/>
<Input
v-model="disableOtpCode"
type="text"
maxlength="6"
:label="$t('MFA_SETTINGS.DISABLE.OTP_CODE')"
:placeholder="$t('MFA_SETTINGS.DISABLE.OTP_CODE_PLACEHOLDER')"
/>
</div>
</Dialog>
<!-- Regenerate Backup Codes Dialog -->
<Dialog
ref="regenerateDialogRef"
type="edit"
:title="$t('MFA_SETTINGS.REGENERATE.TITLE')"
:description="$t('MFA_SETTINGS.REGENERATE.DESCRIPTION')"
:confirm-button-label="$t('MFA_SETTINGS.REGENERATE.CONFIRM')"
:cancel-button-label="$t('MFA_SETTINGS.DISABLE.CANCEL')"
@confirm="handleRegenerateBackupCodes"
>
<Input
v-model="regenerateOtpCode"
type="text"
maxlength="6"
:label="$t('MFA_SETTINGS.REGENERATE.OTP_CODE')"
:placeholder="$t('MFA_SETTINGS.REGENERATE.OTP_CODE_PLACEHOLDER')"
/>
</Dialog>
<!-- Backup Codes Display Dialog -->
<Dialog
ref="backupCodesDialogRef"
type="edit"
width="2xl"
:title="$t('MFA_SETTINGS.REGENERATE.NEW_CODES_TITLE')"
:description="$t('MFA_SETTINGS.REGENERATE.NEW_CODES_DESC')"
:show-cancel-button="false"
:confirm-button-label="$t('MFA_SETTINGS.REGENERATE.CODES_SAVED')"
@confirm="backupCodesDialogRef?.close()"
>
<!-- Warning Alert -->
<div
class="flex items-start gap-2 p-4 bg-n-solid-1 outline outline-n-weak rounded-xl outline-1"
>
<Icon
icon="i-lucide-alert-circle"
class="size-4 text-n-slate-10 flex-shrink-0 mt-0.5"
/>
<p class="text-sm text-n-slate-11">
<strong>{{ $t('MFA_SETTINGS.BACKUP.IMPORTANT') }}</strong>
{{ $t('MFA_SETTINGS.BACKUP.IMPORTANT_NOTE') }}
</p>
</div>
<div
class="bg-n-solid-1 rounded-xl outline-1 outline-n-weak outline flex flex-col gap-6 p-6"
>
<div class="grid grid-cols-2 xs:grid-cols-4 sm:grid-cols-5 gap-3">
<span
v-for="(code, index) in backupCodes"
:key="index"
class="px-1 py-2 font-mono text-base text-center text-n-slate-12"
>
{{ code }}
</span>
</div>
<div class="flex items-center justify-center gap-3">
<Button
outline
slate
sm
icon="i-lucide-download"
:label="$t('MFA_SETTINGS.BACKUP.DOWNLOAD')"
@click="downloadBackupCodes"
/>
<Button
outline
slate
sm
icon="i-lucide-clipboard"
:label="$t('MFA_SETTINGS.BACKUP.COPY_ALL')"
@click="copyBackupCodes"
/>
</div>
</div>
</Dialog>
</div>
<template v-else />
</template>
@@ -0,0 +1,178 @@
<script setup>
import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter, useRoute } from 'vue-router';
import { parseBoolean } from '@chatwoot/utils';
import mfaAPI from 'dashboard/api/mfa';
import { useAlert } from 'dashboard/composables';
import MfaStatusCard from './MfaStatusCard.vue';
import MfaSetupWizard from './MfaSetupWizard.vue';
import MfaManagementActions from './MfaManagementActions.vue';
const { t } = useI18n();
const router = useRouter();
const route = useRoute();
// State
const mfaEnabled = ref(false);
const backupCodesGenerated = ref(false);
const showSetup = ref(false);
const provisioningUri = ref('');
const qrCodeUrl = ref('');
const secretKey = ref('');
const backupCodes = ref([]);
// Component refs
const setupWizardRef = ref(null);
const managementActionsRef = ref(null);
// Load MFA status on mount
onMounted(async () => {
// Check if MFA is enabled globally
if (!parseBoolean(window.chatwootConfig?.isMfaEnabled)) {
// Redirect to profile settings if MFA is disabled
router.push({
name: 'profile_settings_index',
params: {
accountId: route.params.accountId,
},
});
return;
}
try {
const response = await mfaAPI.get();
mfaEnabled.value = response.data.enabled;
backupCodesGenerated.value = response.data.backup_codes_generated;
} catch (error) {
// Handle error silently
}
});
// Start MFA setup
const startMfaSetup = async () => {
try {
const response = await mfaAPI.enable();
// Store the provisioning URI
provisioningUri.value =
response.data.provisioning_uri || response.data.provisioning_url;
// Store QR code URL if provided by backend
if (response.data.qr_code_url) {
qrCodeUrl.value = response.data.qr_code_url;
}
secretKey.value = response.data.secret;
// Backup codes are now generated after verification, not during enable
backupCodes.value = [];
showSetup.value = true;
} catch (error) {
useAlert(t('MFA_SETTINGS.SETUP.ERROR_STARTING'));
}
};
// Verify OTP code
const verifyCode = async verificationCode => {
try {
const response = await mfaAPI.verify(verificationCode);
// Store backup codes returned from verification
if (response.data.backup_codes) {
backupCodes.value = response.data.backup_codes;
}
return true;
} catch (error) {
setupWizardRef.value?.handleVerificationError(
error.response?.data?.error || t('MFA_SETTINGS.SETUP.INVALID_CODE')
);
throw error;
}
};
// Complete MFA setup
const completeMfaSetup = () => {
mfaEnabled.value = true;
backupCodesGenerated.value = true;
showSetup.value = false;
useAlert(t('MFA_SETTINGS.SETUP.SUCCESS'));
};
// Cancel setup
const cancelSetup = () => {
showSetup.value = false;
};
// Disable MFA
const disableMfa = async ({ password, otpCode }) => {
try {
await mfaAPI.disable(password, otpCode);
mfaEnabled.value = false;
backupCodesGenerated.value = false;
managementActionsRef.value?.resetDisableForm();
useAlert(t('MFA_SETTINGS.DISABLE.SUCCESS'));
} catch (error) {
useAlert(t('MFA_SETTINGS.DISABLE.ERROR'));
}
};
// Regenerate backup codes
const regenerateBackupCodes = async ({ otpCode }) => {
try {
const response = await mfaAPI.regenerateBackupCodes(otpCode);
backupCodes.value = response.data.backup_codes;
managementActionsRef.value?.resetRegenerateForm();
managementActionsRef.value?.showBackupCodesDialog();
useAlert(t('MFA_SETTINGS.REGENERATE.SUCCESS'));
} catch (error) {
useAlert(t('MFA_SETTINGS.REGENERATE.ERROR'));
}
};
</script>
<template>
<div
class="grid py-16 px-5 font-inter mx-auto gap-16 sm:max-w-screen-md w-full"
>
<!-- Page Header -->
<div class="flex flex-col gap-6">
<h2 class="text-2xl font-medium text-n-slate-12">
{{ $t('MFA_SETTINGS.TITLE') }}
</h2>
<p class="text-sm text-n-slate-11">
{{ $t('MFA_SETTINGS.SUBTITLE') }}
</p>
</div>
<div class="grid gap-4 w-full">
<!-- MFA Status Card -->
<MfaStatusCard
:mfa-enabled="mfaEnabled"
:show-setup="showSetup"
@enable-mfa="startMfaSetup"
/>
<!-- MFA Setup Wizard -->
<MfaSetupWizard
ref="setupWizardRef"
:show-setup="showSetup"
:mfa-enabled="mfaEnabled"
:provisioning-uri="provisioningUri"
:secret-key="secretKey"
:backup-codes="backupCodes"
:qr-code-url-prop="qrCodeUrl"
@cancel="cancelSetup"
@verify="verifyCode"
@complete="completeMfaSetup"
/>
<!-- MFA Management Actions -->
<MfaManagementActions
ref="managementActionsRef"
:mfa-enabled="mfaEnabled"
:backup-codes="backupCodes"
@disable-mfa="disableMfa"
@regenerate-backup-codes="regenerateBackupCodes"
/>
</div>
</div>
</template>
@@ -0,0 +1,47 @@
<script setup>
import { useRouter, useRoute } from 'vue-router';
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const router = useRouter();
const route = useRoute();
const navigateToMfa = () => {
router.push({
name: 'profile_settings_mfa',
params: {
accountId: route.params.accountId,
},
});
};
</script>
<template>
<div class="bg-n-background rounded-xl p-4 border border-n-slate-4">
<div class="flex flex-col xs:flex-row items-center justify-between gap-4">
<div class="flex flex-col items-start gap-1.5">
<div class="flex items-center gap-2">
<Icon
icon="i-lucide-lock-keyhole"
class="size-4 text-n-slate-10 flex-shrink-0"
/>
<h5 class="text-sm font-semibold text-n-slate-12">
{{ $t('MFA_SETTINGS.TITLE') }}
</h5>
</div>
<p class="text-sm text-n-slate-11">
{{ $t('MFA_SETTINGS.DESCRIPTION') }}
</p>
</div>
<Button
type="button"
faded
:label="$t('PROFILE_SETTINGS.FORM.SECURITY_SECTION.MFA_BUTTON')"
icon="i-lucide-settings"
class="flex-shrink-0"
@click="navigateToMfa"
/>
</div>
</div>
</template>
@@ -0,0 +1,323 @@
<script setup>
import { ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import QRCode from 'qrcode';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { useAlert } from 'dashboard/composables';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
showSetup: {
type: Boolean,
required: true,
},
mfaEnabled: {
type: Boolean,
required: true,
},
provisioningUri: {
type: String,
default: '',
},
secretKey: {
type: String,
default: '',
},
backupCodes: {
type: Array,
default: () => [],
},
qrCodeUrlProp: {
type: String,
default: '',
},
});
const emit = defineEmits(['cancel', 'verify', 'complete']);
const { t } = useI18n();
// Local state
const setupStep = ref('qr');
const qrCodeUrl = ref('');
const verificationCode = ref('');
const verificationError = ref('');
const backupCodesConfirmed = ref(false);
// Generate QR code from provisioning URI
const generateQRCode = async provisioningUrl => {
try {
const qrCodeDataUrl = await QRCode.toDataURL(provisioningUrl, {
width: 256,
margin: 2,
color: {
dark: '#000000',
light: '#FFFFFF',
},
});
return qrCodeDataUrl;
} catch (error) {
return null;
}
};
// Watch for provisioning URI changes
watch(
() => props.provisioningUri,
async newUri => {
if (newUri) {
qrCodeUrl.value = await generateQRCode(newUri);
} else if (props.qrCodeUrlProp) {
qrCodeUrl.value = props.qrCodeUrlProp;
}
},
{ immediate: true }
);
const verifyCode = async () => {
verificationError.value = '';
try {
emit('verify', verificationCode.value);
setupStep.value = 'backup';
verificationCode.value = '';
} catch (error) {
verificationError.value = t('MFA_SETTINGS.SETUP.INVALID_CODE');
}
};
const copySecret = async () => {
await copyTextToClipboard(props.secretKey);
useAlert(t('MFA_SETTINGS.SETUP.SECRET_COPIED'));
};
const copyBackupCodes = async () => {
const codesText = props.backupCodes.join('\n');
await copyTextToClipboard(codesText);
useAlert(t('MFA_SETTINGS.BACKUP.CODES_COPIED'));
};
const downloadBackupCodes = () => {
const codesText = `Chatwoot Two-Factor Authentication Backup Codes\n\n${props.backupCodes.join('\n')}\n\nKeep these codes in a safe place.`;
const blob = new Blob([codesText], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'chatwoot-backup-codes.txt';
a.click();
URL.revokeObjectURL(url);
};
const cancelSetup = () => {
setupStep.value = 'qr';
verificationCode.value = '';
verificationError.value = '';
backupCodesConfirmed.value = false;
emit('cancel');
};
const completeMfaSetup = () => {
setupStep.value = 'qr';
backupCodesConfirmed.value = false;
emit('complete');
};
// Reset when showSetup changes
watch(
() => props.showSetup,
newVal => {
if (newVal) {
setupStep.value = 'qr';
verificationCode.value = '';
verificationError.value = '';
backupCodesConfirmed.value = false;
}
}
);
// Handle verification error
const handleVerificationError = error => {
verificationError.value = error || t('MFA_SETTINGS.SETUP.INVALID_CODE');
};
defineExpose({
handleVerificationError,
setupStep,
});
</script>
<template>
<div v-if="showSetup && !mfaEnabled">
<!-- Step 1: QR Code -->
<div v-if="setupStep === 'qr'" class="space-y-6">
<div
class="bg-n-solid-1 rounded-xl outline-1 outline-n-weak outline p-10 flex flex-col gap-4"
>
<div class="text-center">
<h3 class="text-lg font-medium text-n-slate-12 mb-2">
{{ $t('MFA_SETTINGS.SETUP.STEP1_TITLE') }}
</h3>
<p class="text-sm text-n-slate-11">
{{ $t('MFA_SETTINGS.SETUP.STEP1_DESCRIPTION') }}
</p>
</div>
<div class="flex justify-center">
<div
class="bg-n-background p-4 rounded-lg outline outline-1 outline-n-weak"
>
<img
v-if="qrCodeUrl"
:src="qrCodeUrl"
alt="MFA QR Code"
class="w-48 h-48 dark:invert-0"
/>
<div
v-else
class="w-48 h-48 flex items-center justify-center bg-n-slate-2 dark:bg-n-slate-3"
>
<span class="text-n-slate-10">
{{ $t('MFA_SETTINGS.SETUP.LOADING_QR') }}
</span>
</div>
</div>
</div>
<details class="border border-n-slate-4 rounded-lg">
<summary
class="px-4 py-3 cursor-pointer hover:bg-n-slate-2 dark:hover:bg-n-slate-3 text-sm font-medium text-n-slate-11"
>
{{ $t('MFA_SETTINGS.SETUP.MANUAL_ENTRY') }}
</summary>
<div class="px-4 pb-4">
<label class="block text-xs text-n-slate-10 mb-2">
{{ $t('MFA_SETTINGS.SETUP.SECRET_KEY') }}
</label>
<div class="flex items-center gap-2">
<Input :model-value="secretKey" readonly class="flex-1" />
<Button
variant="outline"
color="slate"
size="sm"
:label="$t('MFA_SETTINGS.SETUP.COPY')"
@click="copySecret"
/>
</div>
</div>
</details>
<div class="flex flex-col items-start gap-3 w-full">
<Input
v-model="verificationCode"
type="text"
maxlength="6"
pattern="[0-9]{6}"
:label="$t('MFA_SETTINGS.SETUP.ENTER_CODE')"
:placeholder="$t('MFA_SETTINGS.SETUP.ENTER_CODE_PLACEHOLDER')"
:message="verificationError"
:message-type="verificationError ? 'error' : 'info'"
class="w-full"
@keyup.enter="verifyCode"
/>
<div class="flex gap-3 mt-1 w-full justify-between">
<Button
faded
color="slate"
class="flex-1"
:label="$t('MFA_SETTINGS.SETUP.CANCEL')"
@click="cancelSetup"
/>
<Button
class="flex-1"
:disabled="verificationCode.length !== 6"
:label="$t('MFA_SETTINGS.SETUP.VERIFY_BUTTON')"
@click="verifyCode"
/>
</div>
</div>
</div>
</div>
<!-- Step 2: Backup Codes -->
<div v-if="setupStep === 'backup'" class="space-y-6">
<div class="text-start">
<h3 class="text-lg font-medium text-n-slate-12 mb-2">
{{ $t('MFA_SETTINGS.BACKUP.TITLE') }}
</h3>
<p class="text-sm text-n-slate-11">
{{ $t('MFA_SETTINGS.BACKUP.DESCRIPTION') }}
</p>
</div>
<!-- Warning Alert -->
<div
class="flex items-start gap-2 p-4 bg-n-solid-1 outline outline-n-weak rounded-xl outline-1"
>
<Icon
icon="i-lucide-alert-circle"
class="size-4 text-n-slate-10 flex-shrink-0 mt-0.5"
/>
<p class="text-sm text-n-slate-11">
<strong>{{ $t('MFA_SETTINGS.BACKUP.IMPORTANT') }}</strong>
{{ $t('MFA_SETTINGS.BACKUP.IMPORTANT_NOTE') }}
</p>
</div>
<!-- Backup Codes Grid -->
<div
class="bg-n-solid-1 rounded-xl outline-1 outline-n-weak outline flex flex-col gap-6 p-6"
>
<div class="grid grid-cols-2 xs:grid-cols-4 sm:grid-cols-5 gap-3">
<span
v-for="(code, index) in backupCodes"
:key="index"
class="px-1 py-2 font-mono text-base text-center text-n-slate-12"
>
{{ code }}
</span>
</div>
<div class="flex items-center justify-center gap-3">
<Button
outline
slate
sm
icon="i-lucide-download"
:label="$t('MFA_SETTINGS.BACKUP.DOWNLOAD')"
@click="downloadBackupCodes"
/>
<Button
outline
slate
sm
icon="i-lucide-clipboard"
:label="$t('MFA_SETTINGS.BACKUP.COPY_ALL')"
@click="copyBackupCodes"
/>
</div>
</div>
<!-- Confirmation -->
<div class="space-y-4">
<label class="flex items-start gap-3">
<input
v-model="backupCodesConfirmed"
type="checkbox"
class="mt-1 rounded border-n-slate-4 text-n-blue-9 focus:ring-n-blue-8"
/>
<span class="text-sm text-n-slate-11">
{{ $t('MFA_SETTINGS.BACKUP.CONFIRM') }}
</span>
</label>
<Button
:disabled="!backupCodesConfirmed"
:label="$t('MFA_SETTINGS.BACKUP.COMPLETE_SETUP')"
@click="completeMfaSetup"
/>
</div>
</div>
</div>
<template v-else />
</template>
@@ -0,0 +1,63 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
defineProps({
mfaEnabled: {
type: Boolean,
required: true,
},
showSetup: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['enableMfa']);
const startSetup = () => {
emit('enableMfa');
};
</script>
<template>
<div v-if="!mfaEnabled && !showSetup" class="space-y-6">
<div
class="bg-n-solid-1 rounded-lg p-6 outline outline-n-weak outline-1 text-center"
>
<Icon
icon="i-lucide-lock-keyhole"
class="size-8 text-n-slate-10 mx-auto mb-4 block"
/>
<h3 class="text-lg font-medium text-n-slate-12 mb-2">
{{ $t('MFA_SETTINGS.ENHANCE_SECURITY') }}
</h3>
<p class="text-sm text-n-slate-11 mb-6 max-w-md mx-auto">
{{ $t('MFA_SETTINGS.ENHANCE_SECURITY_DESC') }}
</p>
<Button
icon="i-lucide-settings"
:label="$t('MFA_SETTINGS.ENABLE_BUTTON')"
@click="startSetup"
/>
</div>
</div>
<div v-else-if="mfaEnabled && !showSetup">
<div
class="bg-n-solid-1 rounded-xl outline-1 outline-n-weak outline p-4 flex-1 flex flex-col gap-2"
>
<div class="flex items-center gap-2">
<Icon
icon="i-lucide-lock-keyhole"
class="size-4 flex-shrink-0 text-n-slate-11"
/>
<h4 class="text-sm font-medium text-n-slate-12">
{{ $t('MFA_SETTINGS.STATUS_ENABLED') }}
</h4>
</div>
<p class="text-sm text-n-slate-11">
{{ $t('MFA_SETTINGS.STATUS_ENABLED_DESC') }}
</p>
</div>
</div>
</template>
@@ -1,7 +1,9 @@
import { frontendURL } from '../../../../helper/URLHelper';
import { parseBoolean } from '@chatwoot/utils';
import SettingsContent from './Wrapper.vue';
import Index from './Index.vue';
import MfaSettings from './MfaSettings.vue';
export default {
routes: [
@@ -21,6 +23,23 @@ export default {
permissions: ['administrator', 'agent', 'custom_role'],
},
},
{
path: 'mfa',
name: 'profile_settings_mfa',
component: MfaSettings,
meta: {
permissions: ['administrator', 'agent', 'custom_role'],
},
beforeEnter: (to, from, next) => {
// Check if MFA is enabled globally
if (!parseBoolean(window.chatwootConfig?.isMfaEnabled)) {
// Redirect to profile settings if MFA is disabled
next({ name: 'profile_settings_index' });
} else {
next();
}
},
},
],
},
],
@@ -285,5 +285,6 @@
"M9.60364 9.20645C9.60364 8.67008 10.0385 8.23523 10.5749 8.23523C11.1113 8.23523 11.5461 8.67008 11.5461 9.20645V11.4511C11.5461 11.9875 11.1113 12.4223 10.5749 12.4223C10.0385 12.4223 9.60364 11.9875 9.60364 11.4511V9.20645Z",
"M17.1442 5.57049C13.5275 5.06019 10.5793 5.04007 6.88135 5.56825C5.9466 5.70176 5.32812 5.79197 4.85654 5.92976C4.41928 6.05757 4.17061 6.20994 3.96492 6.43984C3.539 6.91583 3.48286 7.45419 3.4248 9.33184C3.36775 11.1772 3.48076 12.831 3.69481 14.6918C3.80887 15.6834 3.88736 16.3526 4.01268 16.8613C4.13155 17.3439 4.27532 17.6034 4.47513 17.802C4.67654 18.0023 4.93467 18.1435 5.40841 18.2581C5.90952 18.3793 6.56702 18.4526 7.5442 18.5592C10.7045 18.904 13.0702 18.9022 16.2423 18.561C17.2313 18.4546 17.8995 18.3813 18.4081 18.2609C18.8913 18.1465 19.1511 18.0063 19.3497 17.8118C19.5442 17.6213 19.6928 17.3587 19.8217 16.852C19.9561 16.3234 20.0476 15.624 20.18 14.5966C20.4162 12.7633 20.5863 11.1533 20.5929 9.3896C20.5999 7.50391 20.5613 6.96737 20.1306 6.46971C19.9226 6.22932 19.6696 6.0713 19.2224 5.93968C18.7395 5.79754 18.1042 5.70594 17.1442 5.57049ZM6.65555 3.98715C10.5078 3.43695 13.6072 3.45849 17.3674 3.98902L17.4224 3.99678C18.3127 4.12235 19.0648 4.22844 19.6733 4.40753C20.33 4.60078 20.8792 4.89417 21.3382 5.4245C22.2041 6.42482 22.1984 7.6117 22.1909 9.18858C22.1905 9.25686 22.1902 9.32584 22.19 9.3956C22.183 11.2604 22.0026 12.949 21.764 14.8006L21.7577 14.8496C21.6332 15.8159 21.5307 16.6121 21.3695 17.2458C21.2 17.9121 20.9467 18.4833 20.4672 18.9529C19.9919 19.4183 19.4302 19.6602 18.776 19.8151C18.1582 19.9613 17.3895 20.044 16.4629 20.1436L16.4131 20.149C13.1283 20.5023 10.6472 20.5043 7.37097 20.1469L7.32043 20.1414C6.40679 20.0417 5.64604 19.9587 5.03292 19.8104C4.38112 19.6527 3.82317 19.406 3.34911 18.9347C2.87346 18.4618 2.62363 17.8999 2.46191 17.2433C2.30938 16.6241 2.22071 15.8531 2.11393 14.9246L2.10815 14.8743C1.88863 12.9659 1.76823 11.23 1.82845 9.28246C1.83063 9.2118 1.83272 9.14191 1.83479 9.07281C1.8816 7.50776 1.91671 6.33374 2.7747 5.37486C3.22992 4.86612 3.76798 4.58399 4.40853 4.39678C5.00257 4.22316 5.73505 4.11858 6.60207 3.99479C6.61981 3.99225 6.63764 3.9897 6.65555 3.98715Z"
],
"scan-person-outline": "M5.25 3.5A1.75 1.75 0 0 0 3.5 5.25v3a.75.75 0 0 1-1.5 0v-3A3.25 3.25 0 0 1 5.25 2h3a.75.75 0 0 1 0 1.5zm0 17a1.75 1.75 0 0 1-1.75-1.75v-3a.75.75 0 0 0-1.5 0v3A3.25 3.25 0 0 0 5.25 22h3a.75.75 0 0 0 .707-1l-.005-.015a.75.75 0 0 0-.702-.485zM20.5 5.25a1.75 1.75 0 0 0-1.75-1.75h-3a.75.75 0 0 1 0-1.5h3A3.25 3.25 0 0 1 22 5.25v3a.75.75 0 0 1-1.5 0zM18.75 20.5a1.75 1.75 0 0 0 1.75-1.75v-3a.75.75 0 0 1 1.5 0v3A3.25 3.25 0 0 1 18.75 22h-3a.75.75 0 0 1 0-1.5zM6.5 18.616q0 .465.258.884H5.25a1 1 0 0 1-.129-.011A3.1 3.1 0 0 1 5 18.616v-.366A2.25 2.25 0 0 1 7.25 16h9.5A2.25 2.25 0 0 1 19 18.25v.366c0 .31-.047.601-.132.875a1 1 0 0 1-.118.009h-1.543a1.56 1.56 0 0 0 .293-.884v-.366a.75.75 0 0 0-.75-.75h-9.5a.75.75 0 0 0-.75.75zm8.25-8.866a2.75 2.75 0 1 0-5.5 0a2.75 2.75 0 0 0 5.5 0m1.5 0a4.25 4.25 0 1 1-8.5 0a4.25 4.25 0 0 1 8.5 0"
"scan-person-outline": "M5.25 3.5A1.75 1.75 0 0 0 3.5 5.25v3a.75.75 0 0 1-1.5 0v-3A3.25 3.25 0 0 1 5.25 2h3a.75.75 0 0 1 0 1.5zm0 17a1.75 1.75 0 0 1-1.75-1.75v-3a.75.75 0 0 0-1.5 0v3A3.25 3.25 0 0 0 5.25 22h3a.75.75 0 0 0 .707-1l-.005-.015a.75.75 0 0 0-.702-.485zM20.5 5.25a1.75 1.75 0 0 0-1.75-1.75h-3a.75.75 0 0 1 0-1.5h3A3.25 3.25 0 0 1 22 5.25v3a.75.75 0 0 1-1.5 0zM18.75 20.5a1.75 1.75 0 0 0 1.75-1.75v-3a.75.75 0 0 1 1.5 0v3A3.25 3.25 0 0 1 18.75 22h-3a.75.75 0 0 1 0-1.5zM6.5 18.616q0 .465.258.884H5.25a1 1 0 0 1-.129-.011A3.1 3.1 0 0 1 5 18.616v-.366A2.25 2.25 0 0 1 7.25 16h9.5A2.25 2.25 0 0 1 19 18.25v.366c0 .31-.047.601-.132.875a1 1 0 0 1-.118.009h-1.543a1.56 1.56 0 0 0 .293-.884v-.366a.75.75 0 0 0-.75-.75h-9.5a.75.75 0 0 0-.75.75zm8.25-8.866a2.75 2.75 0 1 0-5.5 0a2.75 2.75 0 0 0 5.5 0m1.5 0a4.25 4.25 0 1 1-8.5 0a4.25 4.25 0 0 1 8.5 0",
"forward-outline": "M14.72 6.28a.75.75 0 0 1 1.06-1.06l5 5a.75.75 0 0 1 0 1.06l-5 5a.75.75 0 1 1-1.06-1.06l3.72-3.72h-7.69a6.25 6.25 0 0 0-6.25 6.25v.5a.75.75 0 0 1-1.5 0v-.5A7.75 7.75 0 0 1 10.75 10h7.69z"
}
@@ -17,3 +17,22 @@ export const copyTextToClipboard = async data => {
throw new Error(`Unable to copy text to clipboard: ${error.message}`);
}
};
/**
* Handles OTP paste events by extracting numeric digits from clipboard data.
*
* @param {ClipboardEvent} event - The paste event from the clipboard
* @param {number} maxLength - Maximum number of digits to extract (default: 6)
* @returns {string|null} - Extracted numeric string or null if invalid
*/
export const handleOtpPaste = (event, maxLength = 6) => {
if (!event?.clipboardData) return null;
const pastedData = event.clipboardData
.getData('text')
.replace(/\D/g, '') // Remove all non-digit characters
.slice(0, maxLength); // Limit to maxLength digits
// Only return if we have the exact expected length
return pastedData.length === maxLength ? pastedData : null;
};
@@ -1,4 +1,4 @@
import { copyTextToClipboard } from '../clipboard';
import { copyTextToClipboard, handleOtpPaste } from '../clipboard';
const mockWriteText = vi.fn();
Object.assign(navigator, {
@@ -172,3 +172,113 @@ describe('copyTextToClipboard', () => {
});
});
});
describe('handleOtpPaste', () => {
// Helper function to create mock clipboard event
const createMockPasteEvent = text => ({
clipboardData: {
getData: vi.fn().mockReturnValue(text),
},
});
describe('valid OTP paste scenarios', () => {
it('extracts 6-digit OTP from clean numeric string', () => {
const event = createMockPasteEvent('123456');
const result = handleOtpPaste(event);
expect(result).toBe('123456');
expect(event.clipboardData.getData).toHaveBeenCalledWith('text');
});
it('extracts 6-digit OTP from string with spaces', () => {
const event = createMockPasteEvent('1 2 3 4 5 6');
const result = handleOtpPaste(event);
expect(result).toBe('123456');
});
it('extracts 6-digit OTP from string with dashes', () => {
const event = createMockPasteEvent('123-456');
const result = handleOtpPaste(event);
expect(result).toBe('123456');
});
it('handles negative numbers by extracting digits only', () => {
const event = createMockPasteEvent('-123456');
const result = handleOtpPaste(event);
expect(result).toBe('123456');
});
it('handles decimal numbers by extracting digits only', () => {
const event = createMockPasteEvent('123.456');
const result = handleOtpPaste(event);
expect(result).toBe('123456');
});
it('extracts 6-digit OTP from mixed alphanumeric string', () => {
const event = createMockPasteEvent('Your code is: 987654');
const result = handleOtpPaste(event);
expect(result).toBe('987654');
});
it('extracts first 6 digits when more than 6 digits present', () => {
const event = createMockPasteEvent('12345678901234');
const result = handleOtpPaste(event);
expect(result).toBe('123456');
});
it('handles custom maxLength parameter', () => {
const event = createMockPasteEvent('12345678');
const result = handleOtpPaste(event, 8);
expect(result).toBe('12345678');
});
it('extracts 4-digit OTP with custom maxLength', () => {
const event = createMockPasteEvent('Your PIN: 9876');
const result = handleOtpPaste(event, 4);
expect(result).toBe('9876');
});
});
describe('invalid OTP paste scenarios', () => {
it('returns null for insufficient digits', () => {
const event = createMockPasteEvent('12345');
const result = handleOtpPaste(event);
expect(result).toBeNull();
});
it('returns null for text with no digits', () => {
const event = createMockPasteEvent('Hello World');
const result = handleOtpPaste(event);
expect(result).toBeNull();
});
it('returns null for empty string', () => {
const event = createMockPasteEvent('');
const result = handleOtpPaste(event);
expect(result).toBeNull();
});
it('returns null when event is null', () => {
const result = handleOtpPaste(null);
expect(result).toBeNull();
});
it('returns null when event is undefined', () => {
const result = handleOtpPaste(undefined);
expect(result).toBeNull();
});
});
});
+5 -1
View File
@@ -15,8 +15,10 @@ export default {
setColorTheme() {
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
this.theme = 'dark';
document.documentElement.classList.add('dark');
} else {
this.theme = 'light ';
this.theme = 'light';
document.documentElement.classList.remove('dark');
}
},
listenToThemeChanges() {
@@ -25,8 +27,10 @@ export default {
mql.onchange = e => {
if (e.matches) {
this.theme = 'dark';
document.documentElement.classList.add('dark');
} else {
this.theme = 'light';
document.documentElement.classList.remove('dark');
}
};
},
+19
View File
@@ -13,6 +13,16 @@ export const login = async ({
}) => {
try {
const response = await wootAPI.post('auth/sign_in', credentials);
// Check if MFA is required
if (response.status === 206 && response.data.mfa_required) {
// Return MFA data instead of throwing error
return {
mfaRequired: true,
mfaToken: response.data.mfa_token,
};
}
setAuthCredentials(response);
clearLocalStorageOnLogout();
window.location = getLoginRedirectURL({
@@ -20,8 +30,17 @@ export const login = async ({
ssoConversationId,
user: response.data.data,
});
return null;
} catch (error) {
// Check if it's an MFA required response
if (error.response?.status === 206 && error.response?.data?.mfa_required) {
return {
mfaRequired: true,
mfaToken: error.response.data.mfa_token,
};
}
throwErrorMessage(error);
return null;
}
};
@@ -62,7 +62,7 @@ export default {
</div>
<SignupForm />
<div class="px-1 text-sm text-n-slate-12">
<span>{{ $t('REGISTER.HAVE_AN_ACCOUNT') }}</span>
<span>{{ $t('REGISTER.HAVE_AN_ACCOUNT') }} </span>
<router-link class="text-link text-n-brand" to="/app/login">
{{ replaceInstallationName($t('LOGIN.TITLE')) }}
</router-link>
+52 -3
View File
@@ -15,6 +15,7 @@ import FormInput from '../../components/Form/Input.vue';
import GoogleOAuthButton from '../../components/GoogleOauth/Button.vue';
import Spinner from 'shared/components/Spinner.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import MfaVerification from 'dashboard/components/auth/MfaVerification.vue';
const ERROR_MESSAGES = {
'no-account-found': 'LOGIN.OAUTH.NO_ACCOUNT_FOUND',
@@ -29,6 +30,7 @@ export default {
GoogleOAuthButton,
Spinner,
NextButton,
MfaVerification,
},
props: {
ssoAuthToken: { type: String, default: '' },
@@ -58,6 +60,8 @@ export default {
hasErrored: false,
},
error: '',
mfaRequired: false,
mfaToken: null,
};
},
validations() {
@@ -87,8 +91,10 @@ export default {
this.submitLogin();
}
if (this.authError) {
const message = ERROR_MESSAGES[this.authError] ?? 'LOGIN.API.UNAUTH';
useAlert(this.$t(message));
const messageKey = ERROR_MESSAGES[this.authError] ?? 'LOGIN.API.UNAUTH';
// Use a method to get the translated text to avoid dynamic key warning
const translatedMessage = this.getTranslatedMessage(messageKey);
useAlert(translatedMessage);
// wait for idle state
this.requestIdleCallbackPolyfill(() => {
// Remove the error query param from the url
@@ -98,6 +104,18 @@ export default {
}
},
methods: {
getTranslatedMessage(key) {
// Avoid dynamic key warning by handling each case explicitly
switch (key) {
case 'LOGIN.OAUTH.NO_ACCOUNT_FOUND':
return this.$t('LOGIN.OAUTH.NO_ACCOUNT_FOUND');
case 'LOGIN.OAUTH.BUSINESS_ACCOUNTS_ONLY':
return this.$t('LOGIN.OAUTH.BUSINESS_ACCOUNTS_ONLY');
case 'LOGIN.API.UNAUTH':
default:
return this.$t('LOGIN.API.UNAUTH');
}
},
// TODO: Remove this when Safari gets wider support
// Ref: https://caniuse.com/requestidlecallback
//
@@ -140,7 +158,15 @@ export default {
};
login(credentials)
.then(() => {
.then(result => {
// Check if MFA is required
if (result?.mfaRequired) {
this.loginApi.showLoading = false;
this.mfaRequired = true;
this.mfaToken = result.mfaToken;
return;
}
this.handleImpersonation();
this.showAlertMessage(this.$t('LOGIN.API.SUCCESS_MESSAGE'));
})
@@ -163,6 +189,17 @@ export default {
this.submitLogin();
},
handleMfaVerified() {
// MFA verification successful, continue with login
this.handleImpersonation();
window.location = '/app';
},
handleMfaCancel() {
// User cancelled MFA, reset state
this.mfaRequired = false;
this.mfaToken = null;
this.credentials.password = '';
},
},
};
</script>
@@ -193,7 +230,19 @@ export default {
</router-link>
</p>
</section>
<!-- MFA Verification Section -->
<section v-if="mfaRequired" class="mt-11">
<MfaVerification
:mfa-token="mfaToken"
@verified="handleMfaVerified"
@cancel="handleMfaCancel"
/>
</section>
<!-- Regular Login Section -->
<section
v-else
class="bg-white shadow sm:mx-auto mt-11 sm:w-full sm:max-w-lg dark:bg-n-solid-2 p-11 sm:shadow-lg sm:rounded-lg"
:class="{
'mb-8 mt-15': !showGoogleOAuth,
+1
View File
@@ -39,6 +39,7 @@ class ConversationReplyMailer < ApplicationMailer
init_conversation_attributes(message.conversation)
@message = message
@has_forwarded_content = message.content_attributes['forwarded_message_id'].present?
reply_mail_object = prepare_mail(true)
message.update(source_id: reply_mail_object.message_id)
end
+1 -1
View File
@@ -36,7 +36,7 @@ class AutomationRule < ApplicationRecord
def conditions_attributes
%w[content email country_code status message_type browser_language assignee_id team_id referer city company inbox_id
mail_subject phone_number priority conversation_language]
mail_subject phone_number priority conversation_language labels]
end
def actions_attributes
+2
View File
@@ -10,6 +10,8 @@ module Labelable
end
def add_labels(new_labels = nil)
return if new_labels.blank?
new_labels = Array(new_labels) # Make sure new_labels is an array
combined_labels = labels + new_labels
update!(label_list: combined_labels)
-2
View File
@@ -297,8 +297,6 @@ class Conversation < ApplicationRecord
previous_labels, current_labels = previous_changes[:label_list]
return unless (previous_labels.is_a? Array) && (current_labels.is_a? Array)
dispatcher_dispatch(CONVERSATION_UPDATED, previous_changes)
create_label_added(user_name, current_labels - previous_labels)
create_label_removed(user_name, previous_labels - current_labels)
end
+10 -4
View File
@@ -7,6 +7,7 @@
# confirmation_sent_at :datetime
# confirmation_token :string
# confirmed_at :datetime
# consumed_timestep :integer
# current_sign_in_at :datetime
# current_sign_in_ip :string
# custom_attributes :jsonb
@@ -17,6 +18,9 @@
# last_sign_in_ip :string
# message_signature :text
# name :string not null
# otp_backup_codes :text
# otp_required_for_login :boolean default(FALSE), not null
# otp_secret :string
# provider :string default("email"), not null
# pubsub_token :string
# remember_created_at :datetime
@@ -33,10 +37,12 @@
#
# Indexes
#
# index_users_on_email (email)
# index_users_on_pubsub_token (pubsub_token) UNIQUE
# index_users_on_reset_password_token (reset_password_token) UNIQUE
# index_users_on_uid_and_provider (uid,provider) UNIQUE
# index_users_on_email (email)
# index_users_on_otp_required_for_login (otp_required_for_login)
# index_users_on_otp_secret (otp_secret) UNIQUE
# index_users_on_pubsub_token (pubsub_token) UNIQUE
# index_users_on_reset_password_token (reset_password_token) UNIQUE
# index_users_on_uid_and_provider (uid,provider) UNIQUE
#
class SuperAdmin < User
end
+38 -4
View File
@@ -7,6 +7,7 @@
# confirmation_sent_at :datetime
# confirmation_token :string
# confirmed_at :datetime
# consumed_timestep :integer
# current_sign_in_at :datetime
# current_sign_in_ip :string
# custom_attributes :jsonb
@@ -17,6 +18,9 @@
# last_sign_in_ip :string
# message_signature :text
# name :string not null
# otp_backup_codes :text
# otp_required_for_login :boolean default(FALSE), not null
# otp_secret :string
# provider :string default("email"), not null
# pubsub_token :string
# remember_created_at :datetime
@@ -33,10 +37,12 @@
#
# Indexes
#
# index_users_on_email (email)
# index_users_on_pubsub_token (pubsub_token) UNIQUE
# index_users_on_reset_password_token (reset_password_token) UNIQUE
# index_users_on_uid_and_provider (uid,provider) UNIQUE
# index_users_on_email (email)
# index_users_on_otp_required_for_login (otp_required_for_login)
# index_users_on_otp_secret (otp_secret) UNIQUE
# index_users_on_pubsub_token (pubsub_token) UNIQUE
# index_users_on_reset_password_token (reset_password_token) UNIQUE
# index_users_on_uid_and_provider (uid,provider) UNIQUE
#
class User < ApplicationRecord
@@ -58,6 +64,7 @@ class User < ApplicationRecord
:validatable,
:confirmable,
:password_has_required_content,
:two_factor_authenticatable,
:omniauthable, omniauth_providers: [:google_oauth2, :saml]
# TODO: remove in a future version once online status is moved to account users
@@ -70,6 +77,12 @@ class User < ApplicationRecord
validates :email, presence: true
serialize :otp_backup_codes, type: Array
# Encrypt sensitive MFA fields
encrypts :otp_secret, deterministic: true
encrypts :otp_backup_codes
has_many :account_users, dependent: :destroy_async
has_many :accounts, through: :account_users
accepts_nested_attributes_for :account_users
@@ -156,6 +169,27 @@ class User < ApplicationRecord
find_by(email: email&.downcase)
end
# 2FA/MFA Methods
# Delegated to Mfa::ManagementService for better separation of concerns
def mfa_service
@mfa_service ||= Mfa::ManagementService.new(user: self)
end
delegate :two_factor_provisioning_uri, to: :mfa_service
delegate :backup_codes_generated?, to: :mfa_service
delegate :enable_two_factor!, to: :mfa_service
delegate :disable_two_factor!, to: :mfa_service
delegate :generate_backup_codes!, to: :mfa_service
delegate :validate_backup_code!, to: :mfa_service
def mfa_enabled?
otp_required_for_login?
end
def mfa_feature_available?
Chatwoot.mfa_enabled?
end
private
def remove_macros
@@ -151,13 +151,36 @@ class AutomationRules::ConditionsFilterService < FilterService
" #{table_name}.additional_attributes ->> '#{attribute_key}' #{filter_operator_value} #{query_operator} "
when 'standard'
if attribute_key == 'labels'
" tags.id #{filter_operator_value} #{query_operator} "
build_label_query_string(query_hash, current_index, query_operator)
else
" #{table_name}.#{attribute_key} #{filter_operator_value} #{query_operator} "
end
end
end
def build_label_query_string(query_hash, current_index, query_operator)
case query_hash['filter_operator']
when 'equal_to'
return " 1=0 #{query_operator} " if query_hash['values'].blank?
value_placeholder = "value_#{current_index}"
@filter_values[value_placeholder] = query_hash['values'].first
" tags.name = :#{value_placeholder} #{query_operator} "
when 'not_equal_to'
return " 1=0 #{query_operator} " if query_hash['values'].blank?
value_placeholder = "value_#{current_index}"
@filter_values[value_placeholder] = query_hash['values'].first
" tags.name != :#{value_placeholder} #{query_operator} "
when 'is_present'
" tags.id IS NOT NULL #{query_operator} "
when 'is_not_present'
" tags.id IS NULL #{query_operator} "
else
" tags.id #{filter_operation(query_hash, current_index)} #{query_operator} "
end
end
private
def base_relation
@@ -166,7 +189,21 @@ class AutomationRules::ConditionsFilterService < FilterService
).joins(
'LEFT OUTER JOIN messages on messages.conversation_id = conversations.id'
)
# Only add label joins when label conditions exist
if label_conditions?
records = records.joins(
'LEFT OUTER JOIN taggings ON taggings.taggable_id = conversations.id AND taggings.taggable_type = \'Conversation\''
).joins(
'LEFT OUTER JOIN tags ON taggings.tag_id = tags.id'
)
end
records = records.where(messages: { id: @options[:message].id }) if @options[:message].present?
records
end
def label_conditions?
@rule.conditions.any? { |condition| condition['attribute_key'] == 'labels' }
end
end
+27
View File
@@ -0,0 +1,27 @@
class BaseTokenService
pattr_initialize [:payload, :token]
def generate_token
JWT.encode(token_payload, secret_key, algorithm)
end
def decode_token
JWT.decode(token, secret_key, true, algorithm: algorithm).first.symbolize_keys
rescue JWT::ExpiredSignature, JWT::DecodeError
{}
end
private
def token_payload
payload || {}
end
def secret_key
Rails.application.secret_key_base
end
def algorithm
'HS256'
end
end
+16 -2
View File
@@ -76,7 +76,8 @@ class Line::IncomingMessageService
response = inbox.channel.client.get_message_content(message['id'])
file_name = "media-#{message['id']}.#{response.content_type.split('/')[1]}"
extension = get_file_extension(response)
file_name = message['fileName'] || "media-#{message['id']}.#{extension}"
temp_file = Tempfile.new(file_name)
temp_file.binmode
temp_file << response.body
@@ -93,12 +94,25 @@ class Line::IncomingMessageService
)
end
def get_file_extension(response)
if response.content_type&.include?('/')
response.content_type.split('/')[1]
else
'bin'
end
end
def event_type_message?(event)
event['type'] == 'message' || event['type'] == 'sticker'
end
def message_type_non_text?(type)
[Line::Bot::Event::MessageType::Video, Line::Bot::Event::MessageType::Audio, Line::Bot::Event::MessageType::Image].include?(type)
[
Line::Bot::Event::MessageType::Video,
Line::Bot::Event::MessageType::Audio,
Line::Bot::Event::MessageType::Image,
Line::Bot::Event::MessageType::File
].include?(type)
end
def account
@@ -0,0 +1,114 @@
# frozen_string_literal: true
class Messages::ForwardedMessageBuilderService
attr_reader :message_id, :params
def initialize(message_id, params = {})
@message_id = message_id
@params = params || {}
end
def perform
return {} unless message_id
return basic_attributes if forwarded_message.blank?
build_forwarded_attributes
end
def formatted_content(original_content = '')
return original_content.to_s if forwarded_message.blank?
data_handler = Messages::ForwardedMessageDataHandlerService.new(forwarded_message, email_data, params)
formatted_info = data_handler.formatted_info
content_builder = Messages::ForwardedMessageContentBuilderService.new(
formatted_info,
forwarded_message,
email_data
)
content_builder.formatted_content(original_content)
end
def forwarded_email_data(original_content = '')
return {} if forwarded_message.blank?
data_handler = Messages::ForwardedMessageDataHandlerService.new(forwarded_message, email_data, params)
data = data_handler.prepare_email_data
formatted_info = data_handler.formatted_info
content_builder = Messages::ForwardedMessageContentBuilderService.new(
formatted_info,
forwarded_message,
email_data
)
process_email_content(data, content_builder, original_content)
end
def forwarded_attachments
forwarded_message.attachments if forwarded_message&.attachments.present?
end
private
def process_email_content(data, content_builder, original_content)
full_content = content_builder.formatted_content(original_content)
process_text_content(data, original_content, full_content)
process_html_content(data, content_builder, original_content, full_content)
add_attachments(data)
data
end
def process_text_content(data, original_content, full_content)
# Convert markdown in original_content to plain text
original_plain = Messages::ForwardedMessageFormatterService.markdown_to_plain_text(original_content.to_s)
# Convert full_content to plain text if it contains markdown
full_plain = Messages::ForwardedMessageFormatterService.markdown_to_plain_text(full_content)
data['text_content'].merge!(
'quoted' => original_plain,
'reply' => full_plain,
'full' => full_plain
)
end
def process_html_content(data, content_builder, original_content, full_content)
stripped = Messages::ForwardedMessageFormatterService.strip_markdown(original_content.to_s)
html_full = content_builder.formatted_html_content(original_content)
data['html_content'].merge!(
'quoted' => stripped,
'reply' => full_content,
'full' => html_full
)
end
def add_attachments(data)
return if forwarded_message.attachments.blank?
data['attachments'] = forwarded_message.attachments.map(&:serializable_hash)
end
def build_forwarded_attributes
{ content_attributes: { forwarded_message_id: message_id, email: prepare_email_data } }
end
def prepare_email_data
data_handler = Messages::ForwardedMessageDataHandlerService.new(forwarded_message, email_data, params)
data_handler.prepare_email_data
end
def forwarded_message
@forwarded_message ||= Message.find_by(id: message_id)
end
def email_data
@email_data ||= forwarded_message&.content_attributes&.dig('email')
end
def basic_attributes
{ content_attributes: { forwarded_message_id: message_id } }
end
end
@@ -0,0 +1,60 @@
# frozen_string_literal: true
class Messages::ForwardedMessageContentBuilderService
attr_reader :formatted_info, :forwarded_message, :email_data
def initialize(formatted_info, forwarded_message, email_data)
@formatted_info = formatted_info
@forwarded_message = forwarded_message
@email_data = email_data
end
def forwarded_header_text
return '' unless formatted_info.values.any?
build_header_lines.compact.join("\n")
end
def forwarded_body_text
return forwarded_message.content.to_s if email_data.blank?
text = email_data.dig('text_content', 'full')
html = email_data.dig('html_content', 'full')
if text.present?
text
elsif html.present?
ActionView::Base.full_sanitizer.sanitize(html)
else
forwarded_message.content.to_s
end
end
def formatted_content(original_content = '')
return original_content.to_s if forwarded_message.blank?
original_content.to_s + forwarded_header_text + forwarded_body_text
end
def formatted_html_content(original_content = '')
return original_content if forwarded_message.blank?
# Always use markdown conversion since it handles plain text correctly
converted_content = Messages::ForwardedMessageFormatterService.convert_markdown_to_html(original_content)
html_builder = Messages::ForwardedMessageHtmlBuilderService.new(formatted_info, forwarded_message, email_data)
html_builder.html_wrapper(converted_content)
end
private
def build_header_lines
[
"\n\n---------- Forwarded message ---------",
("From: #{formatted_info[:from]}" if formatted_info[:from].present?),
("Date: #{formatted_info[:date]}" if formatted_info[:date].present?),
("Subject: #{formatted_info[:subject]}" if formatted_info[:subject].present?),
("To: <#{formatted_info[:to]}>" if formatted_info[:to].present?),
"\n"
]
end
end
@@ -0,0 +1,92 @@
# frozen_string_literal: true
class Messages::ForwardedMessageDataHandlerService
attr_reader :forwarded_message, :email_data, :params
def initialize(forwarded_message, email_data, params = {})
@forwarded_message = forwarded_message
@email_data = email_data
@params = params || {}
end
def prepare_email_data
initialize_email_data
end
def formatted_info
{
from: inbox_email.to_s,
date: Messages::ForwardedMessageFormatterService.format_date_string(email_date),
subject: subject.to_s,
to: recipient_email.to_s
}
end
private
def initialize_email_data
data = base_email_data
add_content_fields(data)
add_header_fields(data)
data
end
def base_email_data
email_data.present? ? email_data.dup || {} : {}
end
def add_content_fields(data)
data['html_content'] ||= {}
data['text_content'] ||= {}
end
def add_header_fields(data)
data['from'] = [email_from_inbox] # Always overwrite with inbox email
data['to'] = Array(params[:to_emails]) if params && params[:to_emails].present?
data['subject'] ||= subject
data['date'] ||= Time.zone.now.to_s
end
def inbox_email
email_from_data || email_from_inbox
end
def email_from_data
return nil unless email_data_has_from?
from_field = email_data['from'].first.to_s
Messages::ForwardedMessageFormatterService.parse_from_field(from_field)
end
def email_data_has_from?
email_data.present? &&
email_data['from'].present? &&
email_data['from'].first.present?
end
def email_from_inbox
inbox = forwarded_message&.conversation&.inbox
return nil if inbox.blank? || inbox.channel_type != 'Channel::Email'
email = inbox.channel&.email
return nil if email.blank?
email
end
def recipient_email
return email_data&.dig('to', 0) if email_data&.dig('to', 0).present?
forwarded_message&.content_attributes&.dig('to_emails', 0).presence
end
def subject
email_data&.dig('subject').presence ||
forwarded_message&.conversation&.additional_attributes&.dig('subject').presence ||
'No Subject'
end
def email_date
email_data&.dig('date').presence || Time.zone.now.to_s
end
end
@@ -0,0 +1,58 @@
# frozen_string_literal: true
class Messages::ForwardedMessageFormatterService
def self.parse_from_field(from_field)
return '' if from_field.blank?
from_field =~ /(.*)<(.*)>/ ? Regexp.last_match(2).strip : from_field
end
def self.format_plain_text_to_html(text)
ERB::Util.html_escape(text.to_s).gsub("\n", '<br>')
end
def self.extract_email(from_field)
return '' if from_field.blank?
from_field =~ /<(.*)>/ ? Regexp.last_match(1) : from_field
end
def self.format_date_string(date_str)
return '' if date_str.blank?
begin
parsed_date = DateTime.now
parsed_date.strftime('%a, %b %-d, %Y at %-l:%M %p')
rescue StandardError
date_str
end
end
def self.strip_markdown(text)
return '' if text.blank?
# Convert markdown to HTML using CommonMarker
html = CommonMarker.render_html(text, :DEFAULT)
# Strip HTML tags to get plain text
ActionView::Base.full_sanitizer.sanitize(html)
end
def self.convert_markdown_to_html(text)
return '' if text.blank?
# Use CommonMarker with GitHub Flavored Markdown options
options = [:GITHUB_PRE_LANG, :UNSAFE]
extensions = [:table, :strikethrough, :autolink]
CommonMarker.render_html(text, options, extensions)
end
# Convert text to plain text, stripping any markdown formatting
def self.markdown_to_plain_text(text)
return '' if text.blank?
# Simply strip any markdown by converting to HTML and sanitizing
strip_markdown(text)
end
end
@@ -0,0 +1,68 @@
# frozen_string_literal: true
class Messages::ForwardedMessageHtmlBuilderService
attr_reader :formatted_info, :forwarded_message, :email_data
def initialize(formatted_info, forwarded_message, email_data)
@formatted_info = formatted_info
@forwarded_message = forwarded_message
@email_data = email_data
end
def html_wrapper(content)
base = '<div dir="ltr">'
if content.blank?
"#{base}#{forwarded_header_html}#{forwarded_body_html}</div>"
else
"#{base}#{content}<br><br>#{forwarded_header_html}#{forwarded_body_html}</div>"
end
end
def forwarded_header_html
from_value = formatted_info[:from].to_s
email = Messages::ForwardedMessageFormatterService.extract_email(from_value)
[
'<div class="gmail_quote gmail_quote_container">',
'<div dir="ltr" class="gmail_attr">---------- Forwarded message ---------<br>',
build_email_html(email),
build_field_html('Date', formatted_info[:date]),
build_field_html('Subject', formatted_info[:subject]),
build_to_html(formatted_info[:to]),
'</div><br><br>'
].compact.join
end
def forwarded_body_html
# Return HTML content directly if available
return email_data.dig('html_content', 'full') if email_data&.dig('html_content', 'full').present?
# Otherwise format content to HTML
content = if email_data&.dig('text_content', 'full').present?
email_data.dig('text_content', 'full')
else
forwarded_message.content.to_s
end
# Always use markdown conversion since it handles plain text correctly
Messages::ForwardedMessageFormatterService.convert_markdown_to_html(content)
end
private
def build_email_html(email)
return nil if email.blank?
"From: &lt;<a href=\"mailto:#{email}\">#{email}</a>&gt;<br>"
end
def build_field_html(label, value)
value.present? ? "#{label}: #{value}<br>" : nil
end
def build_to_html(to_email)
return nil if to_email.blank?
"To: &lt;<a href=\"mailto:#{to_email}\">#{to_email}</a>&gt;<br>"
end
end
@@ -0,0 +1,23 @@
class Mfa::AuthenticationService
pattr_initialize [:user!, :otp_code, :backup_code]
def authenticate
return false unless user
return authenticate_with_otp if otp_code.present?
return authenticate_with_backup_code if backup_code.present?
false
end
private
def authenticate_with_otp
user.validate_and_consume_otp!(otp_code)
end
def authenticate_with_backup_code
mfa_service = Mfa::ManagementService.new(user: user)
mfa_service.validate_backup_code!(backup_code)
end
end
+88
View File
@@ -0,0 +1,88 @@
class Mfa::ManagementService
pattr_initialize [:user!]
def enable_two_factor!
user.otp_secret = User.generate_otp_secret
user.save!
end
def disable_two_factor!
user.otp_secret = nil
user.otp_required_for_login = false
user.otp_backup_codes = nil
user.save!
end
def verify_and_activate!
ActiveRecord::Base.transaction do
user.update!(otp_required_for_login: true)
backup_codes_generated? ? nil : generate_backup_codes!
end
end
def two_factor_provisioning_uri
return nil if user.otp_secret.blank?
issuer = 'Chatwoot'
label = user.email
user.otp_provisioning_uri(label, issuer: issuer)
end
def generate_backup_codes!
codes = Array.new(10) { SecureRandom.hex(4).upcase }
user.otp_backup_codes = codes
user.save!
codes
end
def validate_backup_code!(code)
return false unless valid_backup_code_input?(code)
codes = user.otp_backup_codes
found_index = find_matching_code_index(codes, code)
return false if found_index.nil?
mark_code_as_used(codes, found_index)
end
private
def valid_backup_code_input?(code)
user.otp_backup_codes.present? && code.present?
end
def find_matching_code_index(codes, code)
found_index = nil
# Constant-time comparison to prevent timing attacks
codes.each_with_index do |stored_code, idx|
is_match = ActiveSupport::SecurityUtils.secure_compare(stored_code, code)
is_unused = stored_code != 'XXXXXXXX'
found_index = idx if is_match && is_unused
end
found_index
end
def mark_code_as_used(codes, index)
codes[index] = 'XXXXXXXX'
user.otp_backup_codes = codes
user.save!
true
end
public
def backup_codes_generated?
user.otp_backup_codes.present?
end
def mfa_enabled?
user.otp_required_for_login?
end
def two_factor_setup_pending?
user.otp_secret.present? && !user.otp_required_for_login?
end
end
+28
View File
@@ -0,0 +1,28 @@
class Mfa::TokenService < BaseTokenService
pattr_initialize [:user, :token]
MFA_TOKEN_EXPIRY = 5.minutes
def generate_token
@payload = build_payload
super
end
def verify_token
decoded = decode_token
return nil if decoded.blank?
User.find(decoded[:user_id])
rescue ActiveRecord::RecordNotFound
nil
end
private
def build_payload
{
user_id: user.id,
exp: MFA_TOKEN_EXPIRY.from_now.to_i
}
end
end
@@ -96,8 +96,7 @@ class Whatsapp::PopulateTemplateParametersService
when 'video'
build_video_parameter(sanitized_url)
when 'document'
filename = (media_name.presence || extract_filename_from_url(sanitized_url))
build_document_parameter(sanitized_url, filename)
build_document_parameter(sanitized_url, media_name)
else
raise ArgumentError, "Unsupported media type: #{media_type}"
end
@@ -111,10 +110,11 @@ class Whatsapp::PopulateTemplateParametersService
{ type: 'video', video: { link: url } }
end
def build_document_parameter(url, filename = nil)
document_param = { link: url }
document_param[:filename] = filename if filename.present?
{ type: 'document', document: document_param }
def build_document_parameter(url, media_name = nil)
document_params = { link: url }
document_params[:filename] = media_name if media_name.present?
{ type: 'document', document: document_params }
end
def rich_formatting?(text)
@@ -148,19 +148,4 @@ class Whatsapp::PopulateTemplateParametersService
rescue URI::InvalidURIError => e
raise ArgumentError, "Invalid URL format: #{e.message}. Please enter a valid URL like https://example.com/document.pdf"
end
def extract_filename_from_url(url)
return nil if url.blank?
begin
uri = URI.parse(url)
path = uri.path
filename = File.basename(path)
# Return filename only if it has an extension, otherwise return nil
filename.include?('.') ? filename : nil
rescue URI::InvalidURIError
nil
end
end
end
@@ -141,7 +141,11 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
# {
# processed_params: {
# body: { '1': 'John', '2': '123 Main St' },
# header: { media_url: 'https://...', media_type: 'image' },
# header: {
# media_url: 'https://...',
# media_type: 'image',
# media_name: 'filename.pdf' # Optional, for document templates only
# },
# buttons: [{ type: 'url', parameter: 'otp123456' }]
# }
# }
@@ -60,7 +60,8 @@ class Whatsapp::TemplateProcessorService
next if value.blank?
if media_url_with_type?(key, header_data)
media_param = parameter_builder.build_media_parameter(value, header_data['media_type'], header_data['media_name'])
media_name = header_data['media_name']
media_param = parameter_builder.build_media_parameter(value, header_data['media_type'], media_name)
header_params << media_param if media_param
elsif key != 'media_type' && key != 'media_name'
header_params << parameter_builder.build_parameter(value)
+4 -18
View File
@@ -1,24 +1,14 @@
class Widget::TokenService
class Widget::TokenService < BaseTokenService
DEFAULT_EXPIRY_DAYS = 180
pattr_initialize [:payload, :token]
def generate_token
JWT.encode payload_with_expiry, secret_key, 'HS256'
end
def decode_token
JWT.decode(
token, secret_key, true, algorithm: 'HS256'
).first.symbolize_keys
rescue StandardError
{}
JWT.encode(token_payload, secret_key, algorithm)
end
private
def payload_with_expiry
payload.merge(exp: exp, iat: iat)
def token_payload
(payload || {}).merge(exp: exp, iat: iat)
end
def iat
@@ -34,8 +24,4 @@ class Widget::TokenService
token_expiry_value = InstallationConfig.find_by(name: 'WIDGET_TOKEN_EXPIRY')&.value
(token_expiry_value.presence || DEFAULT_EXPIRY_DAYS).to_i
end
def secret_key
Rails.application.secret_key_base
end
end
@@ -0,0 +1 @@
json.backup_codes @backup_codes
@@ -0,0 +1,2 @@
json.provisioning_url @user.mfa_service.two_factor_provisioning_uri
json.secret @user.otp_secret
@@ -0,0 +1 @@
json.enabled @user.mfa_enabled?
@@ -0,0 +1,3 @@
json.feature_available Chatwoot.mfa_enabled?
json.enabled @user.mfa_enabled?
json.backup_codes_generated @user.mfa_service.backup_codes_generated? if Chatwoot.mfa_enabled?
@@ -0,0 +1,2 @@
json.enabled @user.mfa_enabled?
json.backup_codes @backup_codes if @backup_codes.present?
+1
View File
@@ -44,6 +44,7 @@
whatsappApiVersion: '<%= @global_config['WHATSAPP_API_VERSION'] %>',
signupEnabled: '<%= @global_config['ENABLE_ACCOUNT_SIGNUP'] %>',
isEnterprise: '<%= @global_config['IS_ENTERPRISE'] %>',
isMfaEnabled: '<%= Chatwoot.mfa_enabled? %>',
<% if @global_config['IS_ENTERPRISE'] %>
enterprisePlanName: '<%= @global_config['INSTALLATION_PRICING_PLAN'] %>',
<% end %>
@@ -1,6 +1,23 @@
<% if @message.content %>
<%= ChatwootMarkdownRenderer.new(@message.outgoing_content).render_message %>
<% if @message.content_attributes&.dig('forwarded_message_id').present? %>
<% if @message.content_attributes&.dig('email', 'html_content', 'full').present? %>
<%= @message.content_attributes.dig('email', 'html_content', 'full').html_safe %>
<% elsif @message.content_attributes&.dig('email', 'text_content', 'full').present? %>
<div>
<%= simple_format(@message.content_attributes.dig('email', 'text_content', 'full')) %>
</div>
<% elsif @message.content %>
<div>
<%= ChatwootMarkdownRenderer.new(@message.content).render_message.html_safe %>
</div>
<% end %>
<% else %>
<% if @message.content %>
<div>
<%= ChatwootMarkdownRenderer.new(@message.outgoing_content).render_message %>
</div>
<% end %>
<% end %>
<% if @large_attachments.present? %>
<p>Attachments:</p>
<% @large_attachments.each do |attachment| %>
@@ -0,0 +1,18 @@
<% if @message.content_attributes&.dig('forwarded_message_id').present? %>
<% if @message.content_attributes&.dig('email', 'text_content', 'full').present? %>
<%= @message.content_attributes&.dig('email', 'text_content', 'full') %>
<% elsif @message.content %>
<%= @message.content %>
<% end %>
<% else %>
<% if @message.content %>
<%= @message.content %>
<% end %>
<% end %>
<% if @large_attachments.present? %>
Attachments:
<% @large_attachments.each do |attachment| %>
<%= attachment.file.filename.to_s %>: <%= attachment.file_url %>
<% end %>
<% end %>
+1 -1
View File
@@ -1,5 +1,5 @@
shared: &shared
version: '4.5.2'
version: '4.6.0'
development:
<<: *shared
+22
View File
@@ -68,6 +68,16 @@ module Chatwoot
# Disable PDF/video preview generation as we don't use them
config.active_storage.previewers = []
# Active Record Encryption configuration
# Required for MFA/2FA features - skip if not using encryption
if ENV['ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY'].present?
config.active_record.encryption.primary_key = ENV['ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY']
config.active_record.encryption.deterministic_key = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY', nil)
config.active_record.encryption.key_derivation_salt = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT', nil)
config.active_record.encryption.support_unencrypted_data = true
config.active_record.encryption.store_key_references = true
end
end
def self.config
@@ -82,4 +92,16 @@ module Chatwoot
# ref: https://www.rubydoc.info/stdlib/openssl/OpenSSL/SSL/SSLContext#DEFAULT_PARAMS-constant
ENV['REDIS_OPENSSL_VERIFY_MODE'] == 'none' ? OpenSSL::SSL::VERIFY_NONE : OpenSSL::SSL::VERIFY_PEER
end
def self.encryption_configured?
# Check if proper encryption keys are configured
# MFA/2FA features should only be enabled when proper keys are set
ENV['ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY'].present? &&
ENV['ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY'].present? &&
ENV['ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT'].present?
end
def self.mfa_enabled?
encryption_configured?
end
end
@@ -2,7 +2,8 @@
# Configure sensitive parameters which will be filtered from the log file.
Rails.application.config.filter_parameters += [
:password, :secret, :_key, :auth, :crypt, :salt, :certificate, :otp, :access, :private, :protected, :ssn
:password, :secret, :_key, :auth, :crypt, :salt, :certificate, :otp, :access, :private, :protected, :ssn,
:otp_secret, :otp_code, :backup_code, :mfa_token, :otp_backup_codes
]
# Regex to filter all occurrences of 'token' in keys except for 'website_token'
+29 -2
View File
@@ -83,12 +83,17 @@ class Rack::Attack
end
# ### Prevent Brute-Force Login Attacks ###
# Exclude MFA verification attempts from regular login throttling
throttle('login/ip', limit: 5, period: 5.minutes) do |req|
req.ip if req.path_without_extentions == '/auth/sign_in' && req.post?
if req.path_without_extentions == '/auth/sign_in' && req.post? && req.params['mfa_token'].blank?
# Skip if this is an MFA verification request
req.ip
end
end
throttle('login/email', limit: 10, period: 15.minutes) do |req|
if req.path_without_extentions == '/auth/sign_in' && req.post?
# Skip if this is an MFA verification request
if req.path_without_extentions == '/auth/sign_in' && req.post? && req.params['mfa_token'].blank?
# ref: https://github.com/rack/rack-attack/issues/399
# NOTE: This line used to throw ArgumentError /rails/action_mailbox/sendgrid/inbound_emails : invalid byte sequence in UTF-8
# Hence placed in the if block
@@ -114,6 +119,28 @@ class Rack::Attack
req.ip if req.path_without_extentions == '/api/v1/profile/resend_confirmation' && req.post?
end
## MFA throttling - prevent brute force attacks
throttle('mfa_verification/ip', limit: 5, period: 1.minute) do |req|
if req.path_without_extentions == '/api/v1/profile/mfa'
req.ip if req.delete? # Throttle disable attempts
elsif req.path_without_extentions.match?(%r{/api/v1/profile/mfa/(verify|backup_codes)})
req.ip if req.post? # Throttle verify and backup_codes attempts
end
end
# Separate rate limiting for MFA verification attempts
throttle('mfa_login/ip', limit: 10, period: 1.minute) do |req|
req.ip if req.path_without_extentions == '/auth/sign_in' && req.post? && req.params['mfa_token'].present?
end
throttle('mfa_login/token', limit: 10, period: 1.minute) do |req|
if req.path_without_extentions == '/auth/sign_in' && req.post?
# Track by MFA token to prevent brute force on a specific token
mfa_token = req.params['mfa_token'].presence
(mfa_token.presence)
end
end
## Prevent Brute-Force Signup Attacks ###
throttle('accounts/ip', limit: 5, period: 30.minutes) do |req|
req.ip if req.path_without_extentions == '/api/v1/accounts' && req.post?
+12
View File
@@ -103,6 +103,18 @@ en:
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
custom_attribute_definition:
key_conflict: The provided key is not allowed as it might conflict with default attributes.
mfa:
already_enabled: MFA is already enabled
not_enabled: MFA is not enabled
invalid_code: Invalid verification code
invalid_backup_code: Invalid backup code
invalid_token: Invalid or expired MFA token
invalid_credentials: Invalid credentials or verification code
feature_unavailable: MFA feature is not available. Please configure encryption keys.
profile:
mfa:
enabled: MFA enabled successfully
disabled: MFA disabled successfully
account_saml_settings:
invalid_certificate: must be a valid X.509 certificate in PEM format
reports:
+8
View File
@@ -334,6 +334,14 @@ Rails.application.routes.draw do
post :resend_confirmation
post :reset_access_token
end
# MFA routes
scope module: 'profile' do
resource :mfa, controller: 'mfa', only: [:show, :create, :destroy] do
post :verify
post :backup_codes
end
end
end
resource :notification_subscriptions, only: [:create, :destroy]
@@ -0,0 +1,11 @@
class AddTwoFactorToUsers < ActiveRecord::Migration[7.1]
def change
add_column :users, :otp_secret, :string
add_column :users, :consumed_timestep, :integer
add_column :users, :otp_required_for_login, :boolean, default: false, null: false
add_column :users, :otp_backup_codes, :text
add_index :users, :otp_secret, unique: true
add_index :users, :otp_required_for_login
end
end
+6
View File
@@ -1175,7 +1175,13 @@ ActiveRecord::Schema[7.1].define(version: 2025_09_16_024703) do
t.jsonb "custom_attributes", default: {}
t.string "type"
t.text "message_signature"
t.string "otp_secret"
t.integer "consumed_timestep"
t.boolean "otp_required_for_login", default: false
t.text "otp_backup_codes"
t.index ["email"], name: "index_users_on_email"
t.index ["otp_required_for_login"], name: "index_users_on_otp_required_for_login"
t.index ["otp_secret"], name: "index_users_on_otp_secret", unique: true
t.index ["pubsub_token"], name: "index_users_on_pubsub_token", unique: true
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
t.index ["uid", "provider"], name: "index_users_on_uid_and_provider", unique: true
@@ -10,21 +10,9 @@ class Api::V1::Accounts::Captain::AssistantResponsesController < Api::V1::Accoun
RESULTS_PER_PAGE = 25
def index
base_query = @responses
base_query = base_query.where(assistant_id: permitted_params[:assistant_id]) if permitted_params[:assistant_id].present?
if permitted_params[:document_id].present?
base_query = base_query.where(
documentable_id: permitted_params[:document_id],
documentable_type: 'Captain::Document'
)
end
base_query = base_query.where(status: permitted_params[:status]) if permitted_params[:status].present?
@responses_count = base_query.count
@responses = base_query.page(@current_page).per(RESULTS_PER_PAGE)
filtered_query = apply_filters(@responses)
@responses_count = filtered_query.count
@responses = filtered_query.page(@current_page).per(RESULTS_PER_PAGE)
end
def show; end
@@ -46,6 +34,29 @@ class Api::V1::Accounts::Captain::AssistantResponsesController < Api::V1::Accoun
private
def apply_filters(base_query)
base_query = base_query.where(assistant_id: permitted_params[:assistant_id]) if permitted_params[:assistant_id].present?
if permitted_params[:document_id].present?
base_query = base_query.where(
documentable_id: permitted_params[:document_id],
documentable_type: 'Captain::Document'
)
end
base_query = base_query.where(status: permitted_params[:status]) if permitted_params[:status].present?
if permitted_params[:search].present?
search_term = "%#{permitted_params[:search]}%"
base_query = base_query.where(
'question ILIKE :search OR answer ILIKE :search',
search: search_term
)
end
base_query
end
def set_assistant
@assistant = Current.account.captain_assistants.find_by(id: params[:assistant_id])
end
@@ -63,7 +74,7 @@ class Api::V1::Accounts::Captain::AssistantResponsesController < Api::V1::Accoun
end
def permitted_params
params.permit(:id, :assistant_id, :page, :document_id, :account_id, :status)
params.permit(:id, :assistant_id, :page, :document_id, :account_id, :status, :search)
end
def response_params
+2 -2
View File
@@ -4,7 +4,7 @@
# 3. Automation Filters (app/services/automation_rules/conditions_filter_service.rb), (app/services/automation_rules/condition_validation_service.rb)
# Format
# Format
# - Parent Key (conversation, contact, messages)
# - Key (attribute_name)
# - attribute_type: "standard" : supported ["standard", "additional_attributes (only for conversations and messages)"]
@@ -138,7 +138,7 @@ contacts:
- "does_not_contain"
phone_number:
attribute_type: "standard"
data_type: "text_case_insensitive"
data_type: "text" # Text is not explicity defined in filters, default filter will be used
filter_operators:
- "equal_to"
- "not_equal_to"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@chatwoot/chatwoot",
"version": "4.5.2",
"version": "4.6.0",
"license": "MIT",
"scripts": {
"eslint": "eslint app/**/*.{js,vue}",
@@ -181,4 +181,247 @@ describe Messages::MessageBuilder do
end
end
end
describe '#perform with forwarded messages' do
def create_email_channel_with_inbox
channel = create(:channel_email, account: account)
inbox = channel.inbox
{
channel: channel,
inbox: inbox
}
end
def create_email_conversations(inbox)
{
source: create(:conversation, inbox: inbox, account: account),
target: create(:conversation, inbox: inbox, account: account)
}
end
def standard_email_data
{
'from' => ['sender@example.com'],
'to' => ['recipient@example.com'],
'subject' => 'Test Subject',
'date' => '2025-04-29T14:29:07+05:30',
'html_content' => {
'full' => '<div>HTML content</div>',
'quoted' => 'HTML content',
'reply' => 'HTML content'
},
'text_content' => {
'full' => 'Text content',
'quoted' => 'Text content',
'reply' => 'Text content'
}
}
end
def setup_email_environment
channel_data = create_email_channel_with_inbox
conversations = create_email_conversations(channel_data[:inbox])
{
email_inbox: channel_data[:inbox],
email_conversation: conversations[:source],
target_conversation: conversations[:target],
standard_email_data: standard_email_data
}
end
context 'with different message types' do
let(:env) { setup_email_environment }
it 'preserves email data when forwarding' do
# Create the original message to be forwarded
forwarded_message = create(:message,
conversation: env[:email_conversation],
account: account,
content_attributes: { email: env[:standard_email_data] })
# Setup params to forward the message
forward_params = ActionController::Parameters.new({
content: 'Forwarded message:',
content_attributes: { forwarded_message_id: forwarded_message.id }
})
message = described_class.new(user, env[:target_conversation], forward_params).perform
expect(message.content_attributes[:forwarded_message_id]).to eq(forwarded_message.id)
expect(message.content_attributes[:email]).to be_present
expect(message.content).to include('Forwarded message:')
expect(message.content).to include('---------- Forwarded message ---------')
html_content = message.content_attributes[:email]['html_content']['full']
text_content = message.content_attributes[:email]['text_content']['full']
expect(html_content).to include('<div>HTML content</div>')
expect(text_content).to include('Text content')
end
it 'handles markdown content in forwarded messages' do
# Create the original message to be forwarded
forwarded_message = create(:message,
conversation: env[:email_conversation],
account: account,
content_attributes: { email: env[:standard_email_data] })
markdown_content = '**Bold text** and _italic text_'
forward_params = ActionController::Parameters.new({
content: markdown_content,
content_attributes: { forwarded_message_id: forwarded_message.id }
})
message = described_class.new(user, env[:email_conversation], forward_params).perform
html_content = message.content_attributes[:email]['html_content']['full']
text_quoted = message.content_attributes[:email]['text_content']['quoted']
full_text = message.content_attributes[:email]['text_content']['full']
expect(html_content).to include('<strong>Bold text</strong>')
expect(html_content).to include('<em>italic text</em>')
expect(text_quoted.strip).to eq('Bold text and italic text')
expect(full_text).to include('Bold text and italic text')
expect(full_text).to include('---------- Forwarded message ---------')
end
it 'returns empty email data when forwarding a message with no email data' do
regular_message = create(:message, conversation: conversation, account: account)
forward_params = ActionController::Parameters.new({
content: 'Forwarding a regular message:',
content_attributes: { forwarded_message_id: regular_message.id }
})
# Create the forwarded message
message = described_class.new(user, env[:email_conversation], forward_params).perform
# Updated expectation - we now expect email data to be present but won't check specific content
expect(message.content_attributes[:forwarded_message_id]).to eq(regular_message.id)
expect(message.content_attributes[:email]).to be_present
end
it 'preserves multipart content in forwarded messages' do
# Create a multipart email message
multipart_data = env[:standard_email_data].merge(
'html_content' => { 'full' => '<div>HTML <b>formatted</b> content</div>' },
'text_content' => { 'full' => 'Plain text content' }
)
multipart_message = create(:message,
conversation: env[:email_conversation],
account: account,
content_attributes: { email: multipart_data })
forward_params = ActionController::Parameters.new({
content: 'Forwarding multipart email:',
content_attributes: { forwarded_message_id: multipart_message.id }
})
message = described_class.new(user, env[:email_conversation], forward_params).perform
# Verify multipart content is preserved
expect(message.content_attributes[:email]['html_content']['full']).to include('<div>HTML <b>formatted</b> content</div>')
expect(message.content_attributes[:email]['text_content']['full']).to include('Plain text content')
end
end
context 'with attachments' do
let(:env) { setup_email_environment }
def create_base_message(conversation)
create(:message,
conversation: conversation,
account: account,
content_attributes: {
email: standard_email_data.merge(
'html_content' => { 'full' => '<div>Message with attachments</div>' },
'text_content' => { 'full' => 'Message with attachments' }
)
})
end
def add_text_attachment(message)
attachment = message.attachments.new(account_id: account.id, file_type: 'file')
attachment.file.attach(
io: StringIO.new('test file content'),
filename: 'test.txt',
content_type: 'text/plain'
)
attachment.save!
end
def add_image_attachment(message)
attachment = message.attachments.new(account_id: account.id, file_type: 'image')
attachment.file.attach(
io: StringIO.new('fake image content'),
filename: 'test.jpg',
content_type: 'image/jpeg'
)
attachment.save!
end
def create_message_with_attachments(conversation)
message = create_base_message(conversation)
add_text_attachment(message)
add_image_attachment(message)
message
end
it 'copies attachments from the forwarded message' do
message_with_attachments = create_message_with_attachments(env[:email_conversation])
forward_params = ActionController::Parameters.new({
content: 'Forwarding message with attachments:',
content_attributes: { forwarded_message_id: message_with_attachments.id }
})
message = described_class.new(user, env[:email_conversation], forward_params).perform
# Verify attachments are copied
expect(message.attachments.count).to eq(2)
expect(message.attachments.map(&:file_type)).to include('file', 'image')
expect(message.attachments.first.file).to be_attached
# Verify attachment data in content_attributes
expect(message.content_attributes[:email]['attachments']).to be_present
expect(message.content_attributes[:email]['attachments'].length).to eq(2)
end
end
context 'with nested forwarding' do
let(:env) { setup_email_environment }
it 'maintains proper forwarding chain data' do
# Create original message
parent_message = create(:message,
conversation: env[:email_conversation],
account: account,
content_attributes: { email: env[:standard_email_data] })
# Create first level forward
first_level_params = ActionController::Parameters.new({
content: 'First forwarded message:',
content_attributes: { forwarded_message_id: parent_message.id }
})
first_forward = described_class.new(user, env[:email_conversation], first_level_params).perform
# Create second level forward
second_level_params = ActionController::Parameters.new({
content: 'Forwarding a forwarded message:',
content_attributes: { forwarded_message_id: first_forward.id }
})
message = described_class.new(user, env[:email_conversation], second_level_params).perform
expect(message.content_attributes[:forwarded_message_id]).to be_present
expect(message.content).to include('Forwarding a forwarded message:')
expect(message.content).to include('---------- Forwarded message ---------')
expect(message.content).to include('First forwarded message:')
end
end
end
end
@@ -0,0 +1,146 @@
require 'rails_helper'
RSpec.describe DeviseOverrides::SessionsController, type: :controller do
include Devise::Test::ControllerHelpers
before do
request.env['devise.mapping'] = Devise.mappings[:user]
end
describe 'POST #create' do
let(:user) { create(:user, password: 'Test@123456') }
context 'with standard authentication' do
it 'authenticates with valid credentials' do
post :create, params: { email: user.email, password: 'Test@123456' }
expect(response).to have_http_status(:success)
end
it 'rejects invalid credentials' do
post :create, params: { email: user.email, password: 'wrong' }
expect(response).to have_http_status(:unauthorized)
end
end
context 'with MFA authentication' do
before do
skip('Skipping since MFA is not configured in this environment') unless Chatwoot.encryption_configured?
user.enable_two_factor!
user.update!(otp_required_for_login: true)
end
it 'requires MFA verification after successful password authentication' do
post :create, params: { email: user.email, password: 'Test@123456' }
expect(response).to have_http_status(:partial_content)
json_response = response.parsed_body
expect(json_response['mfa_required']).to be(true)
expect(json_response['mfa_token']).to be_present
end
context 'when verifying MFA' do
let(:mfa_token) { Mfa::TokenService.new(user: user).generate_token }
it 'authenticates with valid OTP' do
post :create, params: {
mfa_token: mfa_token,
otp_code: user.current_otp
}
expect(response).to have_http_status(:success)
end
it 'authenticates with valid backup code' do
backup_codes = user.generate_backup_codes!
post :create, params: {
mfa_token: mfa_token,
backup_code: backup_codes.first
}
expect(response).to have_http_status(:success)
end
it 'rejects invalid OTP' do
post :create, params: {
mfa_token: mfa_token,
otp_code: '000000'
}
expect(response).to have_http_status(:bad_request)
expect(response.parsed_body['error']).to eq(I18n.t('errors.mfa.invalid_code'))
end
it 'rejects invalid backup code' do
user.generate_backup_codes!
post :create, params: {
mfa_token: mfa_token,
backup_code: 'invalid'
}
expect(response).to have_http_status(:bad_request)
expect(response.parsed_body['error']).to eq(I18n.t('errors.mfa.invalid_code'))
end
it 'rejects expired MFA token' do
expired_token = JWT.encode(
{ user_id: user.id, exp: 1.minute.ago.to_i },
Rails.application.secret_key_base,
'HS256'
)
post :create, params: {
mfa_token: expired_token,
otp_code: user.current_otp
}
expect(response).to have_http_status(:unauthorized)
expect(response.parsed_body['error']).to eq(I18n.t('errors.mfa.invalid_token'))
end
it 'requires either OTP or backup code' do
post :create, params: { mfa_token: mfa_token }
expect(response).to have_http_status(:bad_request)
expect(response.parsed_body['error']).to eq(I18n.t('errors.mfa.invalid_code'))
end
end
end
context 'with SSO authentication' do
it 'authenticates with valid SSO token' do
sso_token = user.generate_sso_auth_token
post :create, params: {
email: user.email,
sso_auth_token: sso_token
}
expect(response).to have_http_status(:success)
end
it 'rejects invalid SSO token' do
post :create, params: {
email: user.email,
sso_auth_token: 'invalid'
}
expect(response).to have_http_status(:unauthorized)
end
end
end
describe 'GET #new' do
it 'redirects to frontend login page' do
allow(ENV).to receive(:fetch).and_call_original
allow(ENV).to receive(:fetch).with('FRONTEND_URL', nil).and_return('/frontend')
get :new
expect(response).to redirect_to('/frontend/app/login?error=access-denied')
end
end
end
@@ -90,6 +90,53 @@ RSpec.describe 'Api::V1::Accounts::Captain::AssistantResponses', type: :request
expect(json_response[:payload][0][:documentable][:id]).to eq(document.id)
end
end
context 'when searching' do
before do
create(:captain_assistant_response,
account: account,
assistant: assistant,
question: 'How to reset password?',
answer: 'Click forgot password')
create(:captain_assistant_response,
account: account,
assistant: assistant,
question: 'How to change email?',
answer: 'Go to settings')
end
it 'finds responses by question text' do
get "/api/v1/accounts/#{account.id}/captain/assistant_responses",
params: { search: 'password' },
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:ok)
expect(json_response[:payload].length).to eq(1)
expect(json_response[:payload][0][:question]).to include('password')
end
it 'finds responses by answer text' do
get "/api/v1/accounts/#{account.id}/captain/assistant_responses",
params: { search: 'settings' },
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:ok)
expect(json_response[:payload].length).to eq(1)
expect(json_response[:payload][0][:answer]).to include('settings')
end
it 'returns empty when no matches' do
get "/api/v1/accounts/#{account.id}/captain/assistant_responses",
params: { search: 'nonexistent' },
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:ok)
expect(json_response[:payload].length).to eq(0)
end
end
end
describe 'GET /api/v1/accounts/:account_id/captain/assistant_responses/:id' do

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