Compare commits

..
Author SHA1 Message Date
Sojan 2b9c07be9c Merge branch 'develop' into spike/permissions-for-agents
# Conflicts:
#	app/models/account_user.rb
2024-07-01 14:11:35 -07:00
Shivam MishraandGitHub 5520bf68f3 feat: disable scripts on password reset page (#9693) 2024-07-01 11:11:57 +05:30
Sojan JoseandGitHub 46621b0983 chore: Add permissions to auth data (#9695)
This API change sets the foundation for an upcoming frontend update, transitioning from a role-based model to a permission-based model. This new approach will determine eligibility for various actions and UI elements based on specific permissions rather than roles, enhancing flexibility and security in user access management.
2024-06-28 12:52:48 -07:00
Sivin VargheseandGitHub 2c94c89077 feat: Add video message viewer in agent widget bubble (#9691)
Fixes https://linear.app/chatwoot/issue/CW-3384/video-message-display-issue
2024-06-28 12:51:27 -07:00
Sojan 97de283103 Merge branch 'release/3.10.2' into develop 2024-06-26 17:09:16 -07:00
Sojan 7ed7c1b618 Bump version to 3.10.2 2024-06-26 17:04:56 -07:00
Sivin VargheseandGitHub 8f3234cf4b feat: Add video message viewing in to the user bubble in widget (#9642) 2024-06-26 13:21:41 -07:00
What are you searching?andGitHub b2de6843f6 chore()Update Translate vi.yml (#9656) 2024-06-26 12:54:57 -07:00
73c4180e64 chore(snyk): Upgrade sentry-rails & sentry-sidekiq to 5.18.0 (#9686)
<p>This PR was automatically created by Snyk using the credentials of a
real user.</p><br
/>![snyk-top-banner](https://github.com/andygongea/OWASP-Benchmark/assets/818805/c518c423-16fe-447e-b67f-ad5a49b5d123)

### Snyk has created this PR to fix 1 vulnerabilities in the rubygems
dependencies of this project.

#### Snyk changed the following file(s):

- `Gemfile`
- `Gemfile.lock`




#### Vulnerabilities that will be fixed with an upgrade:

|  | Issue | Score | 

:-------------------------:|:-------------------------|:-------------------------
![medium
severity](https://res.cloudinary.com/snyk/image/upload/w_20,h_20/v1561977819/icon/m.png
'medium severity') | Web Cache Poisoning
<br/>[SNYK-RUBY-RACK-1061917](https://snyk.io/vuln/SNYK-RUBY-RACK-1061917)
| &nbsp;&nbsp;**616**&nbsp;&nbsp;




---

> [!IMPORTANT]
>
> - Check the changes in this PR to ensure they won't cause issues with
your project.
> - Max score is 1000. Note that the real score may have changed since
the PR was raised.
> - This PR was automatically created by Snyk using the credentials of a
real user.

---

**Note:** _You are seeing this because you or someone else with access
to this repository has authorized Snyk to open fix PRs._

For more information: <img
src="https://api.segment.io/v1/pixel/track?data=eyJ3cml0ZUtleSI6InJyWmxZcEdHY2RyTHZsb0lYd0dUcVg4WkFRTnNCOUEwIiwiYW5vbnltb3VzSWQiOiJhM2ZjZWMzOC1mZjk5LTRkNjQtYWU2OS01NDVhZDA2N2FmZjUiLCJldmVudCI6IlBSIHZpZXdlZCIsInByb3BlcnRpZXMiOnsicHJJZCI6ImEzZmNlYzM4LWZmOTktNGQ2NC1hZTY5LTU0NWFkMDY3YWZmNSJ9fQ=="
width="0" height="0"/>
🧐 [View latest project
report](https://app.snyk.io/org/chatwoot/project/b7197bbd-6200-4f23-931d-c39928584360?utm_source&#x3D;github&amp;utm_medium&#x3D;referral&amp;page&#x3D;fix-pr)
📜 [Customise PR
templates](https://docs.snyk.io/scan-using-snyk/pull-requests/snyk-fix-pull-or-merge-requests/customize-pr-templates)
🛠 [Adjust project
settings](https://app.snyk.io/org/chatwoot/project/b7197bbd-6200-4f23-931d-c39928584360?utm_source&#x3D;github&amp;utm_medium&#x3D;referral&amp;page&#x3D;fix-pr/settings)
📚 [Read about Snyk's upgrade
logic](https://support.snyk.io/hc/en-us/articles/360003891078-Snyk-patches-to-fix-vulnerabilities)

---

**Learn how to fix vulnerabilities with free interactive lessons:**

🦉 [Learn about vulnerability in an interactive lesson of Snyk
Learn.](https://learn.snyk.io/?loc&#x3D;fix-pr)

[//]: #
'snyk:metadata:{"customTemplate":{"variablesUsed":[],"fieldsUsed":[]},"dependencies":[{"name":"sentry-rails","from":"5.17.3","to":"5.18.0"},{"name":"sentry-sidekiq","from":"5.17.3","to":"5.18.0"}],"env":"prod","issuesToFix":[{"exploit_maturity":"Proof
of
Concept","id":"SNYK-RUBY-RACK-1061917","priority_score":616,"priority_score_factors":[{"type":"exploit","label":"Proof
of
Concept","score":107},{"type":"fixability","label":true,"score":214},{"type":"cvssScore","label":"5.9","score":295},{"type":"scoreVersion","label":"v1","score":1}],"severity":"medium","title":"Web
Cache Poisoning"},{"exploit_maturity":"Proof of
Concept","id":"SNYK-RUBY-RACK-1061917","priority_score":616,"priority_score_factors":[{"type":"exploit","label":"Proof
of
Concept","score":107},{"type":"fixability","label":true,"score":214},{"type":"cvssScore","label":"5.9","score":295},{"type":"scoreVersion","label":"v1","score":1}],"severity":"medium","title":"Web
Cache Poisoning"},{"exploit_maturity":"Proof of
Concept","id":"SNYK-RUBY-RACK-1061917","priority_score":616,"priority_score_factors":[{"type":"exploit","label":"Proof
of
Concept","score":107},{"type":"fixability","label":true,"score":214},{"type":"cvssScore","label":"5.9","score":295},{"type":"scoreVersion","label":"v1","score":1}],"severity":"medium","title":"Web
Cache
Poisoning"}],"prId":"a3fcec38-ff99-4d64-ae69-545ad067aff5","prPublicId":"a3fcec38-ff99-4d64-ae69-545ad067aff5","packageManager":"rubygems","priorityScoreList":[616],"projectPublicId":"b7197bbd-6200-4f23-931d-c39928584360","projectUrl":"https://app.snyk.io/org/chatwoot/project/b7197bbd-6200-4f23-931d-c39928584360?utm_source=github&utm_medium=referral&page=fix-pr","prType":"fix","templateFieldSources":{"branchName":"default","commitMessage":"default","description":"default","title":"default"},"templateVariants":["priorityScore"],"type":"auto","upgrade":["SNYK-RUBY-RACK-1061917"],"vulns":["SNYK-RUBY-RACK-1061917"],"patch":[],"isBreakingChange":false,"remediationStrategy":"vuln"}'

Co-authored-by: snyk-bot <snyk-bot@snyk.io>
Co-authored-by: Pranav <pranav@chatwoot.com>
2024-06-26 12:46:21 -07:00
96f4f50d2d feat: Add the ability to un-assign teams using automation (#9668)
Co-authored-by: Pranav <pranavrajs@gmail.com>
2024-06-26 12:40:36 -07:00
PranavandGitHub f7f687ce53 fix: Update notification payload (#9666)
- Fix notification payload to avoid argument error.
2024-06-21 17:28:48 -07:00
Sojan 5716bb29b8 Merge branch 'release/3.10.1' into develop 2024-06-21 15:07:09 -07:00
Sojan e72a343612 : Bump version to 3.10.1 2024-06-21 15:06:28 -07:00
PranavandGitHub ee2844877c fix: Add catch for additional webpush errors (#9662)
Webpush gem throws errors such as `WebPush::ExpiredSubscription`,
`WebPush::InvalidSubscription`, `WebPush::Unauthorized`. We handled only
ExpiredSubscription.

If the SDK threw any other errors, it would pause sending the
notification to all other devices for that user. This change would
update the logic to remove the expired subscription and handle the rest
of the errors gracefully.

Fixes
https://linear.app/chatwoot/issue/CW-3399/webpushinvalidsubscription-host-fcmgoogleapiscom-nethttpnotfound-404
2024-06-21 14:58:36 -07:00
Sojan JoseandGitHub 66c6b8cd4f fix: [Snyk] Security upgrade rspec-rails from 6.1.2 to 6.1.3 (#9658)
- security upgrade for gems
2024-06-20 15:36:08 -07:00
PranavandGitHub ef606204a2 fix: Temporarily fix text/plain email rendering (#9653)
This is hacky fix for plain text email rendering. The issue happens only
for the text/plain only emails. If there was an HTML component, then the
rendering works fine.

**How was this tested?**

Mac Email client allows you to send text/plain emails. I've sent one to
myself and imported it on Chatwoot. I've also verified that the email
contains only text/plain part.

Sample rendered email below.

<img width="476" alt="Screenshot 2024-06-18 at 8 15 10 PM"
src="https://github.com/chatwoot/chatwoot/assets/2246121/0c3c07f6-c49d-401a-bba5-a79e82b57bd6">

Fixes https://github.com/chatwoot/chatwoot/issues/9649
Fixes https://github.com/chatwoot/chatwoot/issues/9480
2024-06-18 21:10:41 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
3dee50d2b1 chore(deps): bump ws from 6.2.2 to 6.2.3 (#9650)
Bumps [ws](https://github.com/websockets/ws) from 6.2.2 to 6.2.3.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/websockets/ws/releases">ws's
releases</a>.</em></p>
<blockquote>
<h2>6.2.3</h2>
<h1>Bug fixes</h1>
<ul>
<li>Backported e55e5106 to the 6.x release line (eeb76d31).</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/websockets/ws/commit/d87f3b6d3a00513af9bbb74f45ba9183af4e5f43"><code>d87f3b6</code></a>
[dist] 6.2.3</li>
<li><a
href="https://github.com/websockets/ws/commit/eeb76d313e2a00dd5247ca3597bba7877d064a63"><code>eeb76d3</code></a>
[security] Fix crash when the Upgrade header cannot be read (<a
href="https://redirect.github.com/websockets/ws/issues/2231">#2231</a>)</li>
<li>See full diff in <a
href="https://github.com/websockets/ws/compare/6.2.2...6.2.3">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=ws&package-manager=npm_and_yarn&previous-version=6.2.2&new-version=6.2.3)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/chatwoot/chatwoot/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-18 12:39:27 -07:00
Sojan 1f09af270c Merge branch 'release/3.10.0' into develop 2024-06-17 23:59:31 -07:00
Sojan 6b0a5bea43 Bump version to 3.10.0 2024-06-17 23:58:50 -07:00
Ali BehnamfardandGitHub c5979c4575 feat: Add smtp timeout options (#9613)
- Added two Actionmailer's option (open_timeout & read_timeout) to environment variables.
2024-06-17 22:36:37 -07:00
Muhsin KelothandGitHub 9046730206 feat: FCM HTTP v1 API changes (#9629)
Fixes https://linear.app/chatwoot/issue/CW-3210/legacy-firebase-changes
2024-06-18 10:38:06 +05:30
Sojan JoseandGitHub 7968e98529 chore: Stop processing auto-response emails (#9606)
Stop processing auto-response emails
https://www.notion.so/chatwoot/Avoid-Auto-Replies-sorcerer-s-apprentice-mode-55ffb09efbd7451994f1ff852de4c168?pvs=4
2024-06-13 14:19:11 -07:00
08516e6c43 feat: Enable gmail channel (#9622)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2024-06-13 13:01:05 +05:30
Sojan JoseandGitHub 5abf0e960a chore: Security upgrade for gems (#9635)
- Security upgrade for gems
2024-06-12 20:02:15 -07:00
650fee58a6 feat: add Google Email fetch and OAuth token refresh service (#9603)
This PR adds the following changes

1. Add `Imap::GoogleFetchEmailService` and
`Google::RefreshOauthTokenService`. The
`Google::RefreshOauthTokenService` uses
`OmniAuth::Strategies::GoogleOauth2` which is already added as a packge
2. Update `Inboxes::FetchImapEmailsJob` to handle Google inboxes
3. Add SMTP settings for Google in `ConversationReplyMailerHelper` to
allow sending emails


## Preview

#### Incoming emails

![CleanShot 2024-06-06 at 17 17
22@2x](https://github.com/chatwoot/chatwoot/assets/18097732/9d7d70d1-68e3-4c16-b1ca-e5a2e6f890e8)

#### Outgoing email

![CleanShot 2024-06-06 at 17 18
05@2x](https://github.com/chatwoot/chatwoot/assets/18097732/1b4abf0e-e311-493e-bdc8-386886afbb25)

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2024-06-11 14:22:03 +05:30
Sivin VargheseandGitHub 9689bbf0dd feat: Adds the ability to disable the "new message notification" popup (#9594) 2024-06-11 08:14:58 +05:30
Sivin VargheseandGitHub 20b2dc8c6f fix: Font size and spacing issues in article editor (#9607) 2024-06-07 22:40:52 +05:30
da4b75a3af feat: add Google login flow and inbox creation (#9580)
This PR adds the following changes

1. Refactor `microsoft/callbacks_controller` to move common logic to
`oauth_callback_controller`, most of the logic is re-used for Google
2. Add UI components, `googleClient` and I18n entries for Google login
3. Add Google callback and inbox creation
4. Add a `joinUrl` utility along with specs (need to move it to utils)
5. Add `GoogleConcern`, `Google::AuthorizationsController` and
`Google::CallbacksController`

> Note: The UI is hidden for now, so we can merge this without any
hiccups, to enable it just revert the commit `05c18de`

### Preview


https://github.com/chatwoot/chatwoot/assets/18097732/1606d150-4561-49dc-838d-e0b00fe49ce3

### Linear Tickers

[CW-3370](https://linear.app/chatwoot/issue/CW-3370)
[CW-3371](https://linear.app/chatwoot/issue/CW-3371)

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2024-06-07 16:37:46 +05:30
PranavandGitHub 576c58419c fix: Use message sender ID in the analytics for first response time (#9605)
`conversation.assignee_id` was taken into consideration for first
response time calculation. There was one flaw in this approach. A lot of
customer support agents respond to the conversation first before
assigning it to themselves. This makes the analytics broken.

To fix this, we will take the message sender instead of assignee in
calculations, which should solve the issue with metric.

Fixes
https://linear.app/chatwoot/issue/CW-3375/first-response-reporting-events-use-assignee-id-instead-of-agent-who
2024-06-06 20:20:35 -07:00
Ikko Eltociear AshimineandGitHub 49c3d376de chore: Update typo in inbox_round_robin_service (#9590)
- update the typo in  inbox_round_robin_service
2024-06-06 18:49:53 -07:00
PranavandGitHub 76efe3c5c3 fix: Update the suspended page to show the chat bubble (#9604)
- This PR updates the implementation of chat bubble on suspended account.
2024-06-06 16:39:39 -07:00
Sojan 975d22f1a2 Merge branch 'develop' into spike/permissions-for-agents
# Conflicts:
#	config/installation_config.yml
#	enterprise/app/controllers/enterprise/super_admin/app_configs_controller.rb
2024-06-06 14:00:58 -07:00
75c83dd0d9 chore: Fix for 2 vulnerabilities (#9602)
<p>This PR was automatically created by Snyk using the credentials of a
real user.</p><br
/>![snyk-top-banner](https://github.com/andygongea/OWASP-Benchmark/assets/818805/c518c423-16fe-447e-b67f-ad5a49b5d123)

### Snyk has created this PR to fix 2 vulnerabilities in the rubygems
dependencies of this project.

#### Snyk changed the following file(s):

- `Gemfile`
- `Gemfile.lock`




#### Vulnerabilities that will be fixed with an upgrade:

|  | Issue | Score | 

:-------------------------:|:-------------------------|:-------------------------
![medium
severity](https://res.cloudinary.com/snyk/image/upload/w_20,h_20/v1561977819/icon/m.png
'medium severity') | Improper Input Validation
<br/>[SNYK-RUBY-ACTIONPACK-7210237](https://snyk.io/vuln/SNYK-RUBY-ACTIONPACK-7210237)
| &nbsp;&nbsp;**496**&nbsp;&nbsp;
![medium
severity](https://res.cloudinary.com/snyk/image/upload/w_20,h_20/v1561977819/icon/m.png
'medium severity') | Missing Cryptographic Step
<br/>[SNYK-RUBY-OPENSSL-6036190](https://snyk.io/vuln/SNYK-RUBY-OPENSSL-6036190)
| &nbsp;&nbsp;**479**&nbsp;&nbsp;




---

> [!IMPORTANT]
>
> - Check the changes in this PR to ensure they won't cause issues with
your project.
> - Max score is 1000. Note that the real score may have changed since
the PR was raised.
> - This PR was automatically created by Snyk using the credentials of a
real user.

---

**Note:** _You are seeing this because you or someone else with access
to this repository has authorized Snyk to open fix PRs._

For more information: <img
src="https://api.segment.io/v1/pixel/track?data=eyJ3cml0ZUtleSI6InJyWmxZcEdHY2RyTHZsb0lYd0dUcVg4WkFRTnNCOUEwIiwiYW5vbnltb3VzSWQiOiJmMDU0MzI0Yy1kZjU0LTQ2OTMtYTY1NC1kY2MyZGRmODU1MTIiLCJldmVudCI6IlBSIHZpZXdlZCIsInByb3BlcnRpZXMiOnsicHJJZCI6ImYwNTQzMjRjLWRmNTQtNDY5My1hNjU0LWRjYzJkZGY4NTUxMiJ9fQ=="
width="0" height="0"/>
🧐 [View latest project
report](https://app.snyk.io/org/chatwoot/project/b7197bbd-6200-4f23-931d-c39928584360?utm_source&#x3D;github&amp;utm_medium&#x3D;referral&amp;page&#x3D;fix-pr)
📜 [Customise PR
templates](https://docs.snyk.io/scan-using-snyk/pull-requests/snyk-fix-pull-or-merge-requests/customize-pr-templates)
🛠 [Adjust project
settings](https://app.snyk.io/org/chatwoot/project/b7197bbd-6200-4f23-931d-c39928584360?utm_source&#x3D;github&amp;utm_medium&#x3D;referral&amp;page&#x3D;fix-pr/settings)
📚 [Read about Snyk's upgrade
logic](https://support.snyk.io/hc/en-us/articles/360003891078-Snyk-patches-to-fix-vulnerabilities)

---

**Learn how to fix vulnerabilities with free interactive lessons:**

🦉 [Improper Input
Validation](https://learn.snyk.io/lesson/improper-input-validation/?loc&#x3D;fix-pr)

[//]: #
'snyk:metadata:{"customTemplate":{"variablesUsed":[],"fieldsUsed":[]},"dependencies":[{"name":"dotenv-rails","from":"2.8.1","to":"3.0.0"},{"name":"rails","from":"7.0.8.3","to":"7.0.8.4"},{"name":"rspec-rails","from":"6.0.2","to":"6.0.3"},{"name":"web-push","from":"3.0.0","to":"3.0.1"}],"env":"prod","issuesToFix":[{"exploit_maturity":"No
Known
Exploit","id":"SNYK-RUBY-ACTIONPACK-7210237","priority_score":496,"priority_score_factors":[{"type":"freshness","label":true,"score":71},{"type":"fixability","label":true,"score":214},{"type":"cvssScore","label":"4.2","score":210},{"type":"scoreVersion","label":"v1","score":1}],"severity":"medium","title":"Improper
Input Validation"},{"exploit_maturity":"No Known
Exploit","id":"SNYK-RUBY-ACTIONPACK-7210237","priority_score":496,"priority_score_factors":[{"type":"freshness","label":true,"score":71},{"type":"fixability","label":true,"score":214},{"type":"cvssScore","label":"4.2","score":210},{"type":"scoreVersion","label":"v1","score":1}],"severity":"medium","title":"Improper
Input Validation"},{"exploit_maturity":"No Known
Exploit","id":"SNYK-RUBY-ACTIONPACK-7210237","priority_score":496,"priority_score_factors":[{"type":"freshness","label":true,"score":71},{"type":"fixability","label":true,"score":214},{"type":"cvssScore","label":"4.2","score":210},{"type":"scoreVersion","label":"v1","score":1}],"severity":"medium","title":"Improper
Input Validation"},{"exploit_maturity":"No Known
Exploit","id":"SNYK-RUBY-ACTIONPACK-7210237","priority_score":496,"priority_score_factors":[{"type":"freshness","label":true,"score":71},{"type":"fixability","label":true,"score":214},{"type":"cvssScore","label":"4.2","score":210},{"type":"scoreVersion","label":"v1","score":1}],"severity":"medium","title":"Improper
Input Validation"},{"exploit_maturity":"No Known
Exploit","id":"SNYK-RUBY-ACTIONPACK-7210237","priority_score":496,"priority_score_factors":[{"type":"freshness","label":true,"score":71},{"type":"fixability","label":true,"score":214},{"type":"cvssScore","label":"4.2","score":210},{"type":"scoreVersion","label":"v1","score":1}],"severity":"medium","title":"Improper
Input Validation"},{"exploit_maturity":"No Known
Exploit","id":"SNYK-RUBY-ACTIONPACK-7210237","priority_score":496,"priority_score_factors":[{"type":"freshness","label":true,"score":71},{"type":"fixability","label":true,"score":214},{"type":"cvssScore","label":"4.2","score":210},{"type":"scoreVersion","label":"v1","score":1}],"severity":"medium","title":"Improper
Input Validation"},{"exploit_maturity":"No Known
Exploit","id":"SNYK-RUBY-ACTIONPACK-7210237","priority_score":496,"priority_score_factors":[{"type":"freshness","label":true,"score":71},{"type":"fixability","label":true,"score":214},{"type":"cvssScore","label":"4.2","score":210},{"type":"scoreVersion","label":"v1","score":1}],"severity":"medium","title":"Improper
Input Validation"},{"exploit_maturity":"No Known
Exploit","id":"SNYK-RUBY-ACTIONPACK-7210237","priority_score":496,"priority_score_factors":[{"type":"freshness","label":true,"score":71},{"type":"fixability","label":true,"score":214},{"type":"cvssScore","label":"4.2","score":210},{"type":"scoreVersion","label":"v1","score":1}],"severity":"medium","title":"Improper
Input Validation"},{"exploit_maturity":"No Known
Exploit","id":"SNYK-RUBY-ACTIONPACK-7210237","priority_score":496,"priority_score_factors":[{"type":"freshness","label":true,"score":71},{"type":"fixability","label":true,"score":214},{"type":"cvssScore","label":"4.2","score":210},{"type":"scoreVersion","label":"v1","score":1}],"severity":"medium","title":"Improper
Input Validation"},{"exploit_maturity":"No Known
Exploit","id":"SNYK-RUBY-ACTIONPACK-7210237","priority_score":496,"priority_score_factors":[{"type":"freshness","label":true,"score":71},{"type":"fixability","label":true,"score":214},{"type":"cvssScore","label":"4.2","score":210},{"type":"scoreVersion","label":"v1","score":1}],"severity":"medium","title":"Improper
Input Validation"},{"exploit_maturity":"No Known
Exploit","id":"SNYK-RUBY-ACTIONPACK-7210237","priority_score":496,"priority_score_factors":[{"type":"freshness","label":true,"score":71},{"type":"fixability","label":true,"score":214},{"type":"cvssScore","label":"4.2","score":210},{"type":"scoreVersion","label":"v1","score":1}],"severity":"medium","title":"Improper
Input Validation"},{"exploit_maturity":"No Known
Exploit","id":"SNYK-RUBY-ACTIONPACK-7210237","priority_score":496,"priority_score_factors":[{"type":"freshness","label":true,"score":71},{"type":"fixability","label":true,"score":214},{"type":"cvssScore","label":"4.2","score":210},{"type":"scoreVersion","label":"v1","score":1}],"severity":"medium","title":"Improper
Input Validation"},{"exploit_maturity":"No Known
Exploit","id":"SNYK-RUBY-OPENSSL-6036190","priority_score":479,"priority_score_factors":[{"type":"fixability","label":true,"score":214},{"type":"cvssScore","label":"5.3","score":265},{"type":"scoreVersion","label":"v1","score":1}],"severity":"medium","title":"Missing
Cryptographic
Step"}],"prId":"f054324c-df54-4693-a654-dcc2ddf85512","prPublicId":"f054324c-df54-4693-a654-dcc2ddf85512","packageManager":"rubygems","priorityScoreList":[496,479],"projectPublicId":"b7197bbd-6200-4f23-931d-c39928584360","projectUrl":"https://app.snyk.io/org/chatwoot/project/b7197bbd-6200-4f23-931d-c39928584360?utm_source=github&utm_medium=referral&page=fix-pr","prType":"fix","templateFieldSources":{"branchName":"default","commitMessage":"default","description":"default","title":"default"},"templateVariants":["priorityScore"],"type":"auto","upgrade":["SNYK-RUBY-ACTIONPACK-7210237","SNYK-RUBY-OPENSSL-6036190"],"vulns":["SNYK-RUBY-ACTIONPACK-7210237","SNYK-RUBY-OPENSSL-6036190"],"patch":[],"isBreakingChange":true,"remediationStrategy":"vuln"}'

Co-authored-by: snyk-bot <snyk-bot@snyk.io>
2024-06-06 16:23:48 +05:30
f452565d13 feat: Add search support for drop downs in create linear issue (#9566)
Co-authored-by: iamsivin <iamsivin@gmail.com>
2024-06-05 16:40:10 +05:30
Muhsin KelothandGitHub 839f7fe719 fix: Reset loader state on error (#9597)
- Replace `showAlert` with `showAlertMessage` in auth related pages.
2024-06-05 16:07:44 +05:30
c23e235cea feat: update cwctl to work with Ubuntu 24.04 LTS (#9586)
The `add user` utility was updated, and the `--disabled-login` behavior changed in Ubuntu 24.04. This set the login shell to `/sbin/nologin,` which broke the `cwctl` installation flow. 

ref: https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=625758

Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
2024-06-05 15:20:57 +05:30
Fayaz AhmedandGitHub 88e4d9f06f fix: Missing mitt.js in vue 3 instance of app (#9596) 2024-06-05 15:15:25 +05:30
Fayaz AhmedandGitHub bff19eb2e6 feat: Make context meny visible only on hover (#9592) 2024-06-05 11:13:16 +05:30
Sojan dca8e3a995 chore: Clean up 2024-05-25 01:22:07 -07:00
Sojan f321becdbb chore: permissions 2024-05-25 01:16:04 -07:00
Sojan 3f3c06134c chore: experimenting conversations permissions 2024-05-24 16:15:20 -07:00
Pranav 4897bfd2ef Update permissions on the frontend 2024-05-23 12:12:07 -07:00
Sojan 3a90653d12 chore: test out agent permissions 2024-05-22 18:13:28 -07:00
134 changed files with 3567 additions and 866 deletions
+2
View File
@@ -85,6 +85,8 @@ SMTP_OPENSSL_VERIFY_MODE=peer
# Comment out the following environment variables if required by your SMTP server
# SMTP_TLS=
# SMTP_SSL=
# SMTP_OPEN_TIMEOUT
# SMTP_READ_TIMEOUT
# Mail Incoming
# This is the domain set for the reply emails when conversation continuity is enabled
+1 -2
View File
@@ -75,5 +75,4 @@ yalc.lock
yarn-debug.log*
.yarn-integrity
/storybook-static
config/chatwoot-fcm-service-account-file.json
/storybook-static
+6 -6
View File
@@ -4,7 +4,7 @@ ruby '3.2.2'
##-- base gems for rails --##
gem 'rack-cors', '2.0.0', require: 'rack/cors'
gem 'rails', '~> 7.0.8.1'
gem 'rails', '~> 7.0.8.4'
# Reduces boot times through caching; required in config/boot.rb
gem 'bootsnap', require: false
@@ -61,7 +61,7 @@ gem 'redis-namespace'
gem 'activerecord-import'
##--- gems for server & infra configuration ---##
gem 'dotenv-rails'
gem 'dotenv-rails', '>= 3.0.0'
gem 'foreman'
gem 'puma'
gem 'webpacker'
@@ -111,9 +111,9 @@ gem 'elastic-apm', require: false
gem 'newrelic_rpm', require: false
gem 'newrelic-sidekiq-metrics', '>= 1.6.2', require: false
gem 'scout_apm', require: false
gem 'sentry-rails', '>= 5.14.0', require: false
gem 'sentry-rails', '>= 5.18.0', require: false
gem 'sentry-ruby', require: false
gem 'sentry-sidekiq', '>= 5.15.0', require: false
gem 'sentry-sidekiq', '>= 5.18.0', require: false
##-- background job processing --##
gem 'sidekiq', '>= 7.2.4'
@@ -122,7 +122,7 @@ gem 'sidekiq-cron', '>= 1.12.0'
##-- Push notification service --##
gem 'fcm'
gem 'web-push'
gem 'web-push', '>= 3.0.1'
##-- geocoding / parse location from ip --##
# http://www.rubygeocoder.com/
@@ -228,7 +228,7 @@ group :development, :test do
gem 'mock_redis'
gem 'pry-rails'
gem 'rspec_junit_formatter'
gem 'rspec-rails'
gem 'rspec-rails', '>= 6.1.3'
gem 'rubocop', require: false
gem 'rubocop-performance', require: false
gem 'rubocop-rails', require: false
+97 -99
View File
@@ -33,70 +33,70 @@ GIT
GEM
remote: https://rubygems.org/
specs:
actioncable (7.0.8.3)
actionpack (= 7.0.8.3)
activesupport (= 7.0.8.3)
actioncable (7.0.8.4)
actionpack (= 7.0.8.4)
activesupport (= 7.0.8.4)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
actionmailbox (7.0.8.3)
actionpack (= 7.0.8.3)
activejob (= 7.0.8.3)
activerecord (= 7.0.8.3)
activestorage (= 7.0.8.3)
activesupport (= 7.0.8.3)
actionmailbox (7.0.8.4)
actionpack (= 7.0.8.4)
activejob (= 7.0.8.4)
activerecord (= 7.0.8.4)
activestorage (= 7.0.8.4)
activesupport (= 7.0.8.4)
mail (>= 2.7.1)
net-imap
net-pop
net-smtp
actionmailer (7.0.8.3)
actionpack (= 7.0.8.3)
actionview (= 7.0.8.3)
activejob (= 7.0.8.3)
activesupport (= 7.0.8.3)
actionmailer (7.0.8.4)
actionpack (= 7.0.8.4)
actionview (= 7.0.8.4)
activejob (= 7.0.8.4)
activesupport (= 7.0.8.4)
mail (~> 2.5, >= 2.5.4)
net-imap
net-pop
net-smtp
rails-dom-testing (~> 2.0)
actionpack (7.0.8.3)
actionview (= 7.0.8.3)
activesupport (= 7.0.8.3)
actionpack (7.0.8.4)
actionview (= 7.0.8.4)
activesupport (= 7.0.8.4)
rack (~> 2.0, >= 2.2.4)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (7.0.8.3)
actionpack (= 7.0.8.3)
activerecord (= 7.0.8.3)
activestorage (= 7.0.8.3)
activesupport (= 7.0.8.3)
actiontext (7.0.8.4)
actionpack (= 7.0.8.4)
activerecord (= 7.0.8.4)
activestorage (= 7.0.8.4)
activesupport (= 7.0.8.4)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (7.0.8.3)
activesupport (= 7.0.8.3)
actionview (7.0.8.4)
activesupport (= 7.0.8.4)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0)
active_record_query_trace (1.8)
activejob (7.0.8.3)
activesupport (= 7.0.8.3)
activejob (7.0.8.4)
activesupport (= 7.0.8.4)
globalid (>= 0.3.6)
activemodel (7.0.8.3)
activesupport (= 7.0.8.3)
activerecord (7.0.8.3)
activemodel (= 7.0.8.3)
activesupport (= 7.0.8.3)
activemodel (7.0.8.4)
activesupport (= 7.0.8.4)
activerecord (7.0.8.4)
activemodel (= 7.0.8.4)
activesupport (= 7.0.8.4)
activerecord-import (1.4.1)
activerecord (>= 4.2)
activestorage (7.0.8.3)
actionpack (= 7.0.8.3)
activejob (= 7.0.8.3)
activerecord (= 7.0.8.3)
activesupport (= 7.0.8.3)
activestorage (7.0.8.4)
actionpack (= 7.0.8.4)
activejob (= 7.0.8.4)
activerecord (= 7.0.8.4)
activesupport (= 7.0.8.4)
marcel (~> 1.0)
mini_mime (>= 1.1.0)
activesupport (7.0.8.3)
activesupport (7.0.8.4)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
@@ -150,13 +150,13 @@ GEM
statsd-ruby (~> 1.1)
base64 (0.2.0)
bcrypt (3.1.20)
bigdecimal (3.1.7)
bigdecimal (3.1.8)
bindex (0.8.1)
bootsnap (1.16.0)
msgpack (~> 1.2)
brakeman (5.4.1)
browser (5.3.1)
builder (3.2.4)
builder (3.3.0)
bullet (7.0.7)
activesupport (>= 3.0.0)
uniform_notifier (~> 1.11)
@@ -169,7 +169,7 @@ GEM
climate_control (1.2.0)
coderay (1.1.3)
commonmarker (0.23.10)
concurrent-ruby (1.2.3)
concurrent-ruby (1.3.3)
connection_pool (2.4.1)
crack (0.4.5)
rexml
@@ -204,16 +204,16 @@ GEM
bcrypt (~> 3.0)
devise (> 3.5.2, < 5)
rails (>= 4.2.0, < 7.2)
diff-lcs (1.5.0)
diff-lcs (1.5.1)
digest-crc (0.6.4)
rake (>= 12.0.0, < 14.0.0)
docile (1.4.0)
domain_name (0.5.20190701)
unf (>= 0.0.5, < 1.0.0)
dotenv (2.8.1)
dotenv-rails (2.8.1)
dotenv (= 2.8.1)
railties (>= 3.2)
dotenv (3.1.2)
dotenv-rails (3.1.2)
dotenv (= 3.1.2)
railties (>= 6.1)
down (5.4.0)
addressable (~> 2.8)
ecma-re-validator (0.4.0)
@@ -223,7 +223,7 @@ GEM
http (>= 3.0)
ruby2_keywords
email_reply_trimmer (0.1.13)
erubi (1.12.0)
erubi (1.13.0)
et-orbi (1.2.7)
tzinfo
execjs (2.8.1)
@@ -355,7 +355,6 @@ GEM
hana (1.3.7)
hashdiff (1.0.1)
hashie (5.0.0)
hkdf (1.0.0)
http (5.1.1)
addressable (~> 2.8)
http-cookie (~> 1.0)
@@ -460,8 +459,8 @@ GEM
mime-types-data (3.2023.0218.1)
mini_magick (4.12.0)
mini_mime (1.1.5)
mini_portile2 (2.8.6)
minitest (5.23.0)
mini_portile2 (2.8.7)
minitest (5.24.0)
mock_redis (0.36.0)
ruby2_keywords
msgpack (1.7.0)
@@ -474,7 +473,7 @@ GEM
uri
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
net-imap (0.4.11)
net-imap (0.4.12)
date
net-protocol
net-pop (0.1.2)
@@ -490,14 +489,14 @@ GEM
newrelic_rpm (9.6.0)
base64
nio4r (2.7.3)
nokogiri (1.16.5)
nokogiri (1.16.6)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.16.5-arm64-darwin)
nokogiri (1.16.6-arm64-darwin)
racc (~> 1.4)
nokogiri (1.16.5-x86_64-darwin)
nokogiri (1.16.6-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.16.5-x86_64-linux)
nokogiri (1.16.6-x86_64-linux)
racc (~> 1.4)
oauth (1.1.0)
oauth-tty (~> 1.0, >= 1.0.1)
@@ -527,7 +526,7 @@ GEM
omniauth-rails_csrf_protection (1.0.2)
actionpack (>= 4.2)
omniauth (~> 2.0)
openssl (3.1.0)
openssl (3.2.0)
orm_adapter (0.5.0)
os (1.1.4)
parallel (1.23.0)
@@ -551,7 +550,7 @@ GEM
pundit (2.3.0)
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.7.3)
racc (1.8.0)
rack (2.2.9)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
@@ -569,20 +568,20 @@ GEM
rack-test (2.1.0)
rack (>= 1.3)
rack-timeout (0.6.3)
rails (7.0.8.3)
actioncable (= 7.0.8.3)
actionmailbox (= 7.0.8.3)
actionmailer (= 7.0.8.3)
actionpack (= 7.0.8.3)
actiontext (= 7.0.8.3)
actionview (= 7.0.8.3)
activejob (= 7.0.8.3)
activemodel (= 7.0.8.3)
activerecord (= 7.0.8.3)
activestorage (= 7.0.8.3)
activesupport (= 7.0.8.3)
rails (7.0.8.4)
actioncable (= 7.0.8.4)
actionmailbox (= 7.0.8.4)
actionmailer (= 7.0.8.4)
actionpack (= 7.0.8.4)
actiontext (= 7.0.8.4)
actionview (= 7.0.8.4)
activejob (= 7.0.8.4)
activemodel (= 7.0.8.4)
activerecord (= 7.0.8.4)
activestorage (= 7.0.8.4)
activesupport (= 7.0.8.4)
bundler (>= 1.15.0)
railties (= 7.0.8.3)
railties (= 7.0.8.4)
rails-dom-testing (2.2.0)
activesupport (>= 5.0.0)
minitest
@@ -590,9 +589,9 @@ GEM
rails-html-sanitizer (1.6.0)
loofah (~> 2.21)
nokogiri (~> 1.14)
railties (7.0.8.3)
actionpack (= 7.0.8.3)
activesupport (= 7.0.8.3)
railties (7.0.8.4)
actionpack (= 7.0.8.4)
activesupport (= 7.0.8.4)
method_source
rake (>= 12.2)
thor (~> 1.0)
@@ -604,7 +603,7 @@ GEM
ffi (~> 1.0)
redis (5.0.6)
redis-client (>= 0.9.0)
redis-client (0.22.1)
redis-client (0.22.2)
connection_pool
redis-namespace (1.10.0)
redis (>= 4)
@@ -630,23 +629,23 @@ GEM
nokogiri
rexml (3.2.8)
strscan (>= 3.0.9)
rspec-core (3.12.2)
rspec-support (~> 3.12.0)
rspec-expectations (3.12.3)
rspec-core (3.13.0)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.1)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.12.0)
rspec-mocks (3.12.5)
rspec-support (~> 3.13.0)
rspec-mocks (3.13.1)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.12.0)
rspec-rails (6.0.2)
rspec-support (~> 3.13.0)
rspec-rails (6.1.3)
actionpack (>= 6.1)
activesupport (>= 6.1)
railties (>= 6.1)
rspec-core (~> 3.12)
rspec-expectations (~> 3.12)
rspec-mocks (~> 3.12)
rspec-support (~> 3.12)
rspec-support (3.12.0)
rspec-core (~> 3.13)
rspec-expectations (~> 3.13)
rspec-mocks (~> 3.13)
rspec-support (~> 3.13)
rspec-support (3.13.1)
rspec_junit_formatter (0.6.0)
rspec-core (>= 2, < 4, != 2.12.0)
rubocop (1.50.2)
@@ -704,14 +703,14 @@ GEM
activesupport (>= 4)
selectize-rails (0.12.6)
semantic_range (3.0.0)
sentry-rails (5.17.3)
sentry-rails (5.18.0)
railties (>= 5.0)
sentry-ruby (~> 5.17.3)
sentry-ruby (5.17.3)
sentry-ruby (~> 5.18.0)
sentry-ruby (5.18.0)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
sentry-sidekiq (5.17.3)
sentry-ruby (~> 5.17.3)
sentry-sidekiq (5.18.0)
sentry-ruby (~> 5.18.0)
sidekiq (>= 3.0)
sexp_processor (4.17.0)
shoulda-matchers (5.3.0)
@@ -800,8 +799,7 @@ GEM
activemodel (>= 6.0.0)
bindex (>= 0.4.0)
railties (>= 6.0.0)
web-push (3.0.0)
hkdf (~> 1.0)
web-push (3.0.1)
jwt (~> 2.0)
openssl (~> 3.0)
webmock (3.18.1)
@@ -821,7 +819,7 @@ GEM
working_hours (1.4.1)
activesupport (>= 3.2)
tzinfo
zeitwerk (2.6.14)
zeitwerk (2.6.16)
PLATFORMS
arm64-darwin-20
@@ -863,7 +861,7 @@ DEPENDENCIES
devise (>= 4.9.4)
devise-secure_password!
devise_token_auth (>= 1.2.3)
dotenv-rails
dotenv-rails (>= 3.0.0)
down
elastic-apm
email_reply_trimmer
@@ -918,13 +916,13 @@ DEPENDENCIES
rack-cors (= 2.0.0)
rack-mini-profiler (>= 3.2.0)
rack-timeout
rails (~> 7.0.8.1)
rails (~> 7.0.8.4)
redis
redis-namespace
responders (>= 3.1.1)
rest-client
reverse_markdown
rspec-rails
rspec-rails (>= 6.1.3)
rspec_junit_formatter
rubocop
rubocop-performance
@@ -933,9 +931,9 @@ DEPENDENCIES
scout_apm
scss_lint
seed_dump
sentry-rails (>= 5.14.0)
sentry-rails (>= 5.18.0)
sentry-ruby
sentry-sidekiq (>= 5.15.0)
sentry-sidekiq (>= 5.18.0)
shoulda-matchers
sidekiq (>= 7.2.4)
sidekiq-cron (>= 1.12.0)
@@ -955,7 +953,7 @@ DEPENDENCIES
uglifier
valid_email2
web-console (>= 4.2.1)
web-push
web-push (>= 3.0.1)
webmock
webpacker
wisper (= 2.0.0)
+1 -1
View File
@@ -1 +1 @@
3.3.1
3.9.0
+1 -1
View File
@@ -1 +1 @@
2.7.0
2.8.0
@@ -0,0 +1,32 @@
class Api::V1::Accounts::Google::AuthorizationsController < Api::V1::Accounts::BaseController
include GoogleConcern
before_action :check_authorization
def create
email = params[:authorization][:email]
redirect_url = google_client.auth_code.authorize_url(
{
redirect_uri: "#{base_url}/google/callback",
scope: 'email profile https://mail.google.com/',
response_type: 'code',
prompt: 'consent', # the oauth flow does not return a refresh token, this is supposed to fix it
access_type: 'offline', # the default is 'online'
client_id: GlobalConfigService.load('GOOGLE_OAUTH_CLIENT_ID', nil)
}
)
if redirect_url
cache_key = "google::#{email.downcase}"
::Redis::Alfred.setex(cache_key, Current.account.id, 5.minutes)
render json: { success: true, url: redirect_url }
else
render json: { success: false }, status: :unprocessable_entity
end
end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end
@@ -12,8 +12,8 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts
}
)
if redirect_url
email = email.downcase
::Redis::Alfred.setex(email, Current.account.id, 5.minutes)
cache_key = "microsoft::#{email.downcase}"
::Redis::Alfred.setex(cache_key, Current.account.id, 5.minutes)
render json: { success: true, url: redirect_url }
else
render json: { success: false }, status: :unprocessable_entity
@@ -0,0 +1,20 @@
module GoogleConcern
extend ActiveSupport::Concern
def google_client
app_id = GlobalConfigService.load('GOOGLE_OAUTH_CLIENT_ID', nil)
app_secret = GlobalConfigService.load('GOOGLE_OAUTH_CLIENT_SECRET', nil)
::OAuth2::Client.new(app_id, app_secret, {
site: 'https://oauth2.googleapis.com',
authorize_url: 'https://accounts.google.com/o/oauth2/auth',
token_url: 'https://accounts.google.com/o/oauth2/token'
})
end
private
def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end
end
@@ -15,10 +15,6 @@ module MicrosoftConcern
private
def parsed_body
@parsed_body ||= Rack::Utils.parse_nested_query(@response.raw_response.body)
end
def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end
+11 -1
View File
@@ -37,7 +37,7 @@ class DashboardController < ActionController::Base
end
def set_dashboard_scripts
@dashboard_scripts = GlobalConfig.get_value('DASHBOARD_SCRIPTS')
@dashboard_scripts = sensitive_path? ? nil : GlobalConfig.get_value('DASHBOARD_SCRIPTS')
end
def ensure_installation_onboarding
@@ -75,4 +75,14 @@ class DashboardController < ActionController::Base
'application'
end
end
def sensitive_path?
# dont load dashboard scripts on sensitive paths like password reset
sensitive_paths = [edit_user_password_path].freeze
# remove app prefix
current_path = request.path.gsub(%r{^/app}, '')
sensitive_paths.include?(current_path)
end
end
@@ -0,0 +1,18 @@
class Google::CallbacksController < OauthCallbackController
include GoogleConcern
private
def provider_name
'google'
end
def imap_address
'imap.gmail.com'
end
def oauth_client
# from GoogleConcern
google_client
end
end
@@ -1,90 +1,17 @@
class Microsoft::CallbacksController < ApplicationController
class Microsoft::CallbacksController < OauthCallbackController
include MicrosoftConcern
def show
@response = microsoft_client.auth_code.get_token(
oauth_code,
redirect_uri: "#{base_url}/microsoft/callback"
)
inbox, already_exists = find_or_create_inbox
::Redis::Alfred.delete(users_data['email'].downcase)
if already_exists
redirect_to app_microsoft_inbox_settings_url(account_id: account.id, inbox_id: inbox.id)
else
redirect_to app_microsoft_inbox_agents_url(account_id: account.id, inbox_id: inbox.id)
end
rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception
redirect_to '/'
end
private
def oauth_code
params[:code]
def oauth_client
microsoft_client
end
def users_data
decoded_token = JWT.decode parsed_body[:id_token], nil, false
decoded_token[0]
def provider_name
'microsoft'
end
def parsed_body
@parsed_body ||= @response.response.parsed
end
def account_id
::Redis::Alfred.get(users_data['email'].downcase)
end
def account
@account ||= Account.find(account_id)
end
def find_or_create_inbox
channel_email = Channel::Email.find_by(email: users_data['email'], account: account)
# we need this value to know where to redirect on sucessful processing of the callback
channel_exists = channel_email.present?
channel_email ||= create_microsoft_channel_with_inbox
update_microsoft_channel(channel_email)
# reauthorize channel, this code path only triggers when microsoft auth is successful
# reauthorized will also update cache keys for the associated inbox
channel_email.reauthorized!
[channel_email.inbox, channel_exists]
end
# Fallback name, for when name field is missing from users_data
def fallback_name
users_data['email'].split('@').first.parameterize.titleize
end
def create_microsoft_channel_with_inbox
ActiveRecord::Base.transaction do
channel_email = Channel::Email.create!(email: users_data['email'], account: account)
account.inboxes.create!(
account: account,
channel: channel_email,
name: users_data['name'] || fallback_name
)
channel_email
end
end
def update_microsoft_channel(channel_email)
channel_email.update!({
imap_login: users_data['email'], imap_address: 'outlook.office365.com',
imap_port: '993', imap_enabled: true,
provider: 'microsoft',
provider_config: {
access_token: parsed_body['access_token'],
refresh_token: parsed_body['refresh_token'],
expires_on: (Time.current.utc + 1.hour).to_s
}
})
def imap_address
'outlook.office365.com'
end
end
@@ -0,0 +1,108 @@
class OauthCallbackController < ApplicationController
def show
@response = oauth_client.auth_code.get_token(
oauth_code,
redirect_uri: "#{base_url}/#{provider_name}/callback"
)
handle_response
::Redis::Alfred.delete(cache_key)
rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception
redirect_to '/'
end
private
def handle_response
inbox, already_exists = find_or_create_inbox
if already_exists
redirect_to app_email_inbox_settings_url(account_id: account.id, inbox_id: inbox.id)
else
redirect_to app_email_inbox_agents_url(account_id: account.id, inbox_id: inbox.id)
end
end
def find_or_create_inbox
channel_email = Channel::Email.find_by(email: users_data['email'], account: account)
# we need this value to know where to redirect on sucessful processing of the callback
channel_exists = channel_email.present?
channel_email ||= create_channel_with_inbox
update_channel(channel_email)
# reauthorize channel, this code path only triggers when microsoft auth is successful
# reauthorized will also update cache keys for the associated inbox
channel_email.reauthorized!
[channel_email.inbox, channel_exists]
end
def update_channel(channel_email)
channel_email.update!({
imap_login: users_data['email'], imap_address: imap_address,
imap_port: '993', imap_enabled: true,
provider: provider_name,
provider_config: {
access_token: parsed_body['access_token'],
refresh_token: parsed_body['refresh_token'],
expires_on: (Time.current.utc + 1.hour).to_s
}
})
end
def provider_name
raise NotImplementedError
end
def oauth_client
raise NotImplementedError
end
def cache_key
"#{provider_name}::#{users_data['email'].downcase}"
end
def create_channel_with_inbox
ActiveRecord::Base.transaction do
channel_email = Channel::Email.create!(email: users_data['email'], account: account)
account.inboxes.create!(
account: account,
channel: channel_email,
name: users_data['name'] || fallback_name
)
channel_email
end
end
def users_data
decoded_token = JWT.decode parsed_body[:id_token], nil, false
decoded_token[0]
end
def account_id
::Redis::Alfred.get(cache_key)
end
def account
@account ||= Account.find(account_id)
end
# Fallback name, for when name field is missing from users_data
def fallback_name
users_data['email'].split('@').first.parameterize.titleize
end
def oauth_code
params[:code]
end
def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end
def parsed_body
@parsed_body ||= @response.response.parsed
end
end
@@ -0,0 +1,14 @@
/* global axios */
import ApiClient from '../ApiClient';
class MicrosoftClient extends ApiClient {
constructor() {
super('google', { accountScoped: true });
}
generateAuthorization(payload) {
return axios.post(`${this.url}/authorization`, payload);
}
}
export default new MicrosoftClient();
@@ -1,6 +1,6 @@
<template>
<button
class="bg-white dark:bg-slate-900 cursor-pointer flex flex-col transition-all duration-200 ease-in -m-px py-4 px-0 items-center border border-solid border-slate-25 dark:border-slate-800 hover:border-woot-500 dark:hover:border-woot-500 hover:shadow-md hover:z-50 disabled:opacity-60"
class="bg-white dark:bg-slate-900 cursor-pointer flex flex-col justify-end transition-all duration-200 ease-in -m-px py-4 px-0 items-center border border-solid border-slate-25 dark:border-slate-800 hover:border-woot-500 dark:hover:border-woot-500 hover:shadow-md hover:z-50 disabled:opacity-60"
@click="$emit('click')"
>
<img :src="src" :alt="title" class="w-1/2 my-4 mx-auto" />
@@ -314,14 +314,28 @@ export default {
unassigned: 'unAssignedCount',
all: 'allCount',
};
return Object.keys(ASSIGNEE_TYPE_TAB_KEYS).map(key => {
const count = this.conversationStats[ASSIGNEE_TYPE_TAB_KEYS[key]] || 0;
return {
key,
name: this.$t(`CHAT_LIST.ASSIGNEE_TYPE_TABS.${key}`),
count,
};
});
const userPermissions = this.currentUser.permissions;
const allowedTabs = {
me: ['conversation_manage', 'conversation_participating_manage'].some(
permission => userPermissions.includes(permission)
),
unassigned: [
'conversation_manage',
'conversation_unassigned_manage',
].some(permission => userPermissions.includes(permission)),
all: true,
};
return Object.keys(allowedTabs)
.filter(key => allowedTabs[key])
.map(key => {
const count =
this.conversationStats[ASSIGNEE_TYPE_TAB_KEYS[key]] || 0;
return {
key,
name: this.$t(`CHAT_LIST.ASSIGNEE_TYPE_TABS.${key}`),
count,
};
});
},
showAssigneeInConversationCard() {
return (
@@ -20,7 +20,7 @@
:teams="teams"
:custom-views="customViews"
:menu-config="activeSecondaryMenu"
:current-role="currentRole"
:current-user="currentUser"
:is-on-chatwoot-cloud="isOnChatwootCloud"
@add-label="showAddLabelPopup"
@toggle-accounts="toggleAccountModal"
@@ -98,9 +98,12 @@ export default {
return getSidebarItems(this.accountId);
},
primaryMenuItems() {
const permissions = this.currentUser.permissions;
const menuItems = this.sideMenuConfig.primaryMenu;
return menuItems.filter(menuItem => {
const isAvailableForTheUser = menuItem.roles.includes(this.currentRole);
const isAvailableForTheUser = permissions.some(permission =>
menuItem.meta.permissions.includes(permission)
);
if (!isAvailableForTheUser) {
return false;
@@ -11,6 +11,9 @@ const campaigns = accountId => ({
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/campaigns/ongoing`),
toStateName: 'ongoing_campaigns',
meta: {
permissions: ['account_manage'],
},
},
{
key: 'oneOffCampaigns',
@@ -19,6 +22,9 @@ const campaigns = accountId => ({
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/campaigns/one_off`),
toStateName: 'one_off',
meta: {
permissions: ['account_manage'],
},
},
],
});
@@ -14,6 +14,9 @@ const contacts = accountId => ({
label: 'ALL_CONTACTS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/contacts`),
meta: {
permissions: ['account_manage'],
},
toStateName: 'contacts_dashboard',
},
],
@@ -28,6 +28,9 @@ const conversations = accountId => ({
key: 'conversations',
toState: frontendURL(`accounts/${accountId}/dashboard`),
toolTip: 'Conversation from all subscribed inboxes',
meta: {
permissions: ['account_manage'],
},
toStateName: 'home',
},
{
@@ -35,6 +38,9 @@ const conversations = accountId => ({
label: 'MENTIONED_CONVERSATIONS',
key: 'conversation_mentions',
toState: frontendURL(`accounts/${accountId}/mentions/conversations`),
meta: {
permissions: ['account_manage'],
},
toStateName: 'conversation_mentions',
},
{
@@ -42,6 +48,9 @@ const conversations = accountId => ({
label: 'UNATTENDED_CONVERSATIONS',
key: 'conversation_unattended',
toState: frontendURL(`accounts/${accountId}/unattended/conversations`),
meta: {
permissions: ['account_manage'],
},
toStateName: 'conversation_unattended',
},
],
@@ -9,7 +9,14 @@ const primaryMenuItems = accountId => [
featureFlag: FEATURE_FLAGS.INBOX_VIEW,
toState: frontendURL(`accounts/${accountId}/inbox-view`),
toStateName: 'inbox_view',
roles: ['administrator', 'agent'],
meta: {
permissions: [
'account_manage',
'conversation_manage',
'conversation_participating_manage',
'conversation_unassigned_manage',
],
},
},
{
icon: 'chat',
@@ -17,7 +24,14 @@ const primaryMenuItems = accountId => [
label: 'CONVERSATIONS',
toState: frontendURL(`accounts/${accountId}/dashboard`),
toStateName: 'home',
roles: ['administrator', 'agent'],
meta: {
permissions: [
'account_manage',
'conversation_manage',
'conversation_participating_manage',
'conversation_unassigned_manage',
],
},
},
{
icon: 'book-contacts',
@@ -26,7 +40,14 @@ const primaryMenuItems = accountId => [
featureFlag: FEATURE_FLAGS.CRM,
toState: frontendURL(`accounts/${accountId}/contacts`),
toStateName: 'contacts_dashboard',
roles: ['administrator', 'agent'],
meta: {
permissions: [
'account_manage',
'conversation_manage',
'conversation_participating_manage',
'conversation_unassigned_manage',
],
},
},
{
icon: 'arrow-trending-lines',
@@ -35,7 +56,9 @@ const primaryMenuItems = accountId => [
featureFlag: FEATURE_FLAGS.REPORTS,
toState: frontendURL(`accounts/${accountId}/reports`),
toStateName: 'settings_account_reports',
roles: ['administrator'],
meta: {
permissions: ['account_manage'],
},
},
{
icon: 'megaphone',
@@ -44,7 +67,9 @@ const primaryMenuItems = accountId => [
featureFlag: FEATURE_FLAGS.CAMPAIGNS,
toState: frontendURL(`accounts/${accountId}/campaigns`),
toStateName: 'ongoing_campaigns',
roles: ['administrator'],
meta: {
permissions: ['account_manage'],
},
},
{
icon: 'library',
@@ -54,7 +79,9 @@ const primaryMenuItems = accountId => [
alwaysVisibleOnChatwootInstances: true,
toState: frontendURL(`accounts/${accountId}/portals`),
toStateName: 'default_portal_articles',
roles: ['administrator'],
meta: {
permissions: ['account_manage'],
},
},
{
icon: 'settings',
@@ -62,7 +89,14 @@ const primaryMenuItems = accountId => [
label: 'SETTINGS',
toState: frontendURL(`accounts/${accountId}/settings`),
toStateName: 'settings_home',
roles: ['administrator', 'agent'],
meta: {
permissions: [
'account_manage',
'conversation_manage',
'conversation_participating_manage',
'conversation_unassigned_manage',
],
},
},
];
@@ -20,6 +20,9 @@ const reports = accountId => ({
label: 'REPORTS_OVERVIEW',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/reports/overview`),
meta: {
permissions: ['account_manage'],
},
toStateName: 'account_overview_reports',
},
{
@@ -27,6 +30,9 @@ const reports = accountId => ({
label: 'REPORTS_CONVERSATION',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/reports/conversation`),
meta: {
permissions: ['account_manage'],
},
toStateName: 'conversation_reports',
},
{
@@ -34,6 +40,9 @@ const reports = accountId => ({
label: 'CSAT',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/reports/csat`),
meta: {
permissions: ['account_manage'],
},
toStateName: 'csat_reports',
},
{
@@ -42,6 +51,9 @@ const reports = accountId => ({
hasSubMenu: false,
featureFlag: FEATURE_FLAGS.RESPONSE_BOT,
toState: frontendURL(`accounts/${accountId}/reports/bot`),
meta: {
permissions: ['account_manage'],
},
toStateName: 'bot_reports',
},
{
@@ -49,6 +61,9 @@ const reports = accountId => ({
label: 'REPORTS_AGENT',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/reports/agent`),
meta: {
permissions: ['account_manage'],
},
toStateName: 'agent_reports',
},
{
@@ -56,6 +71,9 @@ const reports = accountId => ({
label: 'REPORTS_LABEL',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/reports/label`),
meta: {
permissions: ['account_manage'],
},
toStateName: 'label_reports',
},
{
@@ -63,6 +81,9 @@ const reports = accountId => ({
label: 'REPORTS_INBOX',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/reports/inboxes`),
meta: {
permissions: ['account_manage'],
},
toStateName: 'inbox_reports',
},
{
@@ -70,6 +91,9 @@ const reports = accountId => ({
label: 'REPORTS_TEAM',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/reports/teams`),
meta: {
permissions: ['account_manage'],
},
toStateName: 'team_reports',
},
{
@@ -78,6 +102,9 @@ const reports = accountId => ({
hasSubMenu: false,
featureFlag: FEATURE_FLAGS.SLA,
toState: frontendURL(`accounts/${accountId}/reports/sla`),
meta: {
permissions: ['account_manage'],
},
toStateName: 'sla_reports',
},
],
@@ -47,6 +47,9 @@ const settings = accountId => ({
label: 'ACCOUNT_SETTINGS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/general`),
meta: {
permissions: ['account_manage'],
},
toStateName: 'general_settings_index',
},
{
@@ -55,6 +58,9 @@ const settings = accountId => ({
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/agents/list`),
toStateName: 'agent_list',
meta: {
permissions: ['account_manage'],
},
featureFlag: FEATURE_FLAGS.AGENT_MANAGEMENT,
},
{
@@ -63,6 +69,9 @@ const settings = accountId => ({
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/teams/list`),
toStateName: 'settings_teams_list',
meta: {
permissions: ['account_manage'],
},
featureFlag: FEATURE_FLAGS.TEAM_MANAGEMENT,
},
{
@@ -71,6 +80,9 @@ const settings = accountId => ({
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/inboxes/list`),
toStateName: 'settings_inbox_list',
meta: {
permissions: ['account_manage'],
},
featureFlag: FEATURE_FLAGS.INBOX_MANAGEMENT,
},
{
@@ -79,6 +91,9 @@ const settings = accountId => ({
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/labels/list`),
toStateName: 'labels_list',
meta: {
permissions: ['account_manage'],
},
featureFlag: FEATURE_FLAGS.LABELS,
},
{
@@ -89,6 +104,9 @@ const settings = accountId => ({
`accounts/${accountId}/settings/custom-attributes/list`
),
toStateName: 'attributes_list',
meta: {
permissions: ['account_manage'],
},
featureFlag: FEATURE_FLAGS.CUSTOM_ATTRIBUTES,
},
{
@@ -97,6 +115,9 @@ const settings = accountId => ({
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/automation/list`),
toStateName: 'automation_list',
meta: {
permissions: ['account_manage'],
},
featureFlag: FEATURE_FLAGS.AUTOMATIONS,
},
{
@@ -106,6 +127,9 @@ const settings = accountId => ({
globalConfigFlag: 'csmlEditorHost',
toState: frontendURL(`accounts/${accountId}/settings/agent-bots`),
toStateName: 'agent_bots',
meta: {
permissions: ['account_manage'],
},
featureFlag: FEATURE_FLAGS.AGENT_BOTS,
},
{
@@ -114,6 +138,9 @@ const settings = accountId => ({
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/macros`),
toStateName: 'macros_wrapper',
meta: {
permissions: ['account_manage'],
},
featureFlag: FEATURE_FLAGS.MACROS,
},
{
@@ -124,6 +151,9 @@ const settings = accountId => ({
`accounts/${accountId}/settings/canned-response/list`
),
toStateName: 'canned_list',
meta: {
permissions: ['account_manage'],
},
featureFlag: FEATURE_FLAGS.CANNED_RESPONSES,
},
{
@@ -132,6 +162,9 @@ const settings = accountId => ({
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/integrations`),
toStateName: 'settings_integrations',
meta: {
permissions: ['account_manage'],
},
featureFlag: FEATURE_FLAGS.INTEGRATIONS,
},
{
@@ -140,6 +173,9 @@ const settings = accountId => ({
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/applications`),
toStateName: 'settings_applications',
meta: {
permissions: ['account_manage'],
},
featureFlag: FEATURE_FLAGS.INTEGRATIONS,
},
{
@@ -150,6 +186,9 @@ const settings = accountId => ({
toStateName: 'auditlogs_list',
isEnterpriseOnly: true,
featureFlag: FEATURE_FLAGS.AUDIT_LOGS,
meta: {
permissions: ['account_manage'],
},
beta: true,
},
{
@@ -160,6 +199,9 @@ const settings = accountId => ({
toStateName: 'sla_list',
isEnterpriseOnly: true,
featureFlag: FEATURE_FLAGS.SLA,
meta: {
permissions: ['account_manage'],
},
beta: true,
},
{
@@ -168,6 +210,9 @@ const settings = accountId => ({
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/billing`),
toStateName: 'billing_settings_index',
meta: {
permissions: ['account_manage'],
},
showOnlyOnCloud: true,
},
],
@@ -60,9 +60,9 @@ export default {
type: Object,
default: () => {},
},
currentRole: {
type: String,
default: '',
currentUser: {
type: Object,
default: () => {},
},
isOnChatwootCloud: {
type: Boolean,
@@ -80,16 +80,16 @@ export default {
return this.customViews.filter(view => view.filter_type === 'contact');
},
accessibleMenuItems() {
if (!this.currentRole) {
return [];
}
const menuItemsFilteredByRole = this.menuConfig.menuItems.filter(
menuItem =>
window.roleWiseRoutes[this.currentRole].indexOf(
menuItem.toStateName
) > -1
const menuItemsFilteredByPermissions = this.menuConfig.menuItems.filter(
menuItem => {
const { meta: { permissions: menuPermissions = [] } = {} } = menuItem;
const { permissions: userPermissions = [] } = this.currentUser;
return menuPermissions.some(permission =>
userPermissions.includes(permission)
);
}
);
return menuItemsFilteredByRole.filter(item => {
return menuItemsFilteredByPermissions.filter(item => {
if (item.showOnlyOnCloud) {
return this.isOnChatwootCloud;
}
@@ -65,27 +65,29 @@
:show-child-count="showChildCount(child.count)"
:child-item-count="child.count"
/>
<router-link
v-if="showItem(menuItem)"
v-slot="{ href, navigate }"
:to="menuItem.toState"
custom
>
<li class="pl-1">
<a :href="href">
<woot-button
size="tiny"
variant="clear"
color-scheme="secondary"
icon="add"
:data-testid="menuItem.dataTestid"
@click="e => newLinkClick(e, navigate)"
>
{{ $t(`SIDEBAR.${menuItem.newLinkTag}`) }}
</woot-button>
</a>
</li>
</router-link>
<Policy :permissions="['account_manage']">
<router-link
v-if="menuItem.newLink"
v-slot="{ href, navigate }"
:to="menuItem.toState"
custom
>
<li class="pl-1">
<a :href="href">
<woot-button
size="tiny"
variant="clear"
color-scheme="secondary"
icon="add"
:data-testid="menuItem.dataTestid"
@click="e => newLinkClick(e, navigate)"
>
{{ $t(`SIDEBAR.${menuItem.newLinkTag}`) }}
</woot-button>
</a>
</li>
</router-link>
</Policy>
</ul>
</li>
</template>
@@ -105,9 +107,10 @@ import {
isOnMentionsView,
isOnUnattendedView,
} from '../../../store/modules/conversations/helpers/actionHelpers';
import Policy from '../../policy.vue';
export default {
components: { SecondaryChildNavItem },
components: { SecondaryChildNavItem, Policy },
mixins: [adminMixin, configMixin],
props: {
menuItem: {
@@ -0,0 +1,24 @@
<script setup>
import { useStoreGetters } from 'dashboard/composables/store';
import { computed } from 'vue';
const props = defineProps({
permissions: {
type: Array,
required: true,
},
});
const getters = useStoreGetters();
const user = getters.getCurrentUser.value;
const hasPermission = computed(() =>
props.permissions.some(permission => user.permissions.includes(permission))
);
</script>
<template>
<div v-if="hasPermission">
<slot />
</div>
</template>
@@ -13,7 +13,7 @@ defineProps({
<template>
<button
class="relative inline-flex items-center justify-start w-full p-3 border-0 rounded-none first:rounded-t-xl last:rounded-b-xl h-11 hover:bg-slate-50 dark:hover:bg-slate-700 active:bg-slate-75 dark:active:bg-slate-800"
@click.stop="$emit('click')"
@click.stop.prevent="$emit('click')"
@mouseenter="$emit('mouseenter')"
@mouseleave="$emit('mouseleave')"
@focus="$emit('focus')"
@@ -1,5 +1,9 @@
<template>
<li v-if="shouldRenderMessage" :id="`message${data.id}`" :class="alignBubble">
<li
v-if="shouldRenderMessage"
:id="`message${data.id}`"
:class="[alignBubble, 'group']"
>
<div :class="wrapClass">
<div
v-if="isFailed && !hasOneDayPassed && !isAnEmailInbox"
@@ -121,7 +125,10 @@
</a>
</div>
</div>
<div v-if="shouldShowContextMenu" class="context-menu-wrap">
<div
v-if="shouldShowContextMenu"
class="context-menu-wrap invisible group-hover:visible"
>
<context-menu
v-if="isBubble && !isMessageDeleted"
:context-menu-position="contextMenuPosition"
@@ -250,7 +257,16 @@ export default {
html_content: { full: fullHTMLContent } = {},
text_content: { full: fullTextContent } = {},
} = this.contentAttributes.email || {};
return fullHTMLContent || fullTextContent || '';
if (fullHTMLContent) {
return fullHTMLContent;
}
if (fullTextContent) {
return fullTextContent.replace(/\n/g, '<br>');
}
return '';
},
displayQuotedButton() {
if (this.emailMessageContent.includes('<blockquote')) {
@@ -1,5 +1,5 @@
<template>
<div @submit.prevent="onSubmit">
<div>
<woot-input
v-model="formState.title"
:class="{ error: v$.title.$error }"
@@ -26,61 +26,19 @@
"
/>
</label>
<label :class="{ error: v$.teamId.$error }">
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TEAM.LABEL') }}
<select
v-model="formState.teamId"
:style="inputStyles"
@change="onChangeTeam"
>
<option v-for="item in teams" :key="item.name" :value="item.id">
{{ item.name }}
</option>
</select>
<span v-if="v$.teamId.$error" class="message">
{{ teamError }}
</span>
</label>
<label>
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.ASSIGNEE.LABEL') }}
<select v-model="formState.assigneeId" :style="inputStyles">
<option v-for="item in assignees" :key="item.name" :value="item.id">
{{ item.name }}
</option>
</select>
</label>
<label>
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.LABEL.LABEL') }}
<select v-model="formState.labelId" :style="inputStyles">
<option v-for="item in labels" :key="item.name" :value="item.id">
{{ item.name }}
</option>
</select>
</label>
<label>
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.PRIORITY.LABEL') }}
<select v-model="formState.priority" :style="inputStyles">
<option v-for="item in priorities" :key="item.name" :value="item.id">
{{ item.name }}
</option>
</select>
</label>
<label>
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.PROJECT.LABEL') }}
<select v-model="formState.projectId" :style="inputStyles">
<option v-for="item in projects" :key="item.name" :value="item.id">
{{ item.name }}
</option>
</select>
</label>
<label>
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.STATUS.LABEL') }}
<select v-model="formState.stateId" :style="inputStyles">
<option v-for="item in statuses" :key="item.name" :value="item.id">
{{ item.name }}
</option>
</select>
</label>
<div class="flex flex-col gap-4">
<searchable-dropdown
v-for="dropdown in dropdowns"
:key="dropdown.type"
:type="dropdown.type"
:value="formState[dropdown.type]"
:label="$t(dropdown.label)"
:items="dropdown.items"
:placeholder="$t(dropdown.placeholder)"
:error="dropdown.error"
@change="onChange"
/>
</div>
<div class="flex items-center justify-end w-full gap-2 mt-8">
<woot-button
class="px-4 rounded-xl button clear outline-woot-200/50 outline"
@@ -108,6 +66,7 @@ import { useAlert } from 'dashboard/composables';
import LinearAPI from 'dashboard/api/integrations/linear';
import validations from './validations';
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
import SearchableDropdown from './SearchableDropdown.vue';
const props = defineProps({
accountId: {
@@ -162,7 +121,6 @@ const formState = reactive({
priority: '',
projectId: '',
});
const v$ = useVuelidate(validations, formState);
const isSubmitDisabled = computed(
@@ -179,6 +137,56 @@ const teamError = computed(() =>
: ''
);
const dropdowns = computed(() => {
return [
{
type: 'teamId',
label: 'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TEAM.LABEL',
items: teams.value,
placeholder: 'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TEAM.SEARCH',
error: teamError.value,
},
{
type: 'assigneeId',
label: 'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.ASSIGNEE.LABEL',
items: assignees.value,
placeholder:
'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.ASSIGNEE.SEARCH',
error: '',
},
{
type: 'labelId',
label: 'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.LABEL.LABEL',
items: labels.value,
placeholder: 'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.LABEL.SEARCH',
error: '',
},
{
type: 'priority',
label: 'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.PRIORITY.LABEL',
items: priorities,
placeholder:
'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.PRIORITY.SEARCH',
error: '',
},
{
type: 'projectId',
label: 'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.PROJECT.LABEL',
items: projects.value,
placeholder:
'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.PROJECT.SEARCH',
error: '',
},
{
type: 'stateId',
label: 'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.STATUS.LABEL',
items: statuses.value,
placeholder: 'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.STATUS.SEARCH',
error: '',
},
];
});
const onClose = () => emit('close');
const getTeams = async () => {
@@ -212,12 +220,15 @@ const getTeamEntities = async () => {
}
};
const onChangeTeam = event => {
formState.teamId = event.target.value;
formState.assigneeId = '';
formState.stateId = '';
formState.labelId = '';
getTeamEntities();
const onChange = (item, type) => {
formState[type] = item.id;
if (type === 'teamId') {
formState.assigneeId = '';
formState.stateId = '';
formState.labelId = '';
formState.projectId = '';
getTeamEntities();
}
};
const createIssue = async () => {
@@ -6,7 +6,7 @@
<filter-button
right-icon="chevron-down"
:button-text="linkIssueTitle"
class="justify-between w-full bg-slate-50 dark:bg-slate-800 hover:bg-slate-75 dark:hover:bg-slate-800"
class="justify-between w-full h-[2.5rem] py-1.5 px-3 rounded-xl border border-slate-50 bg-slate-25 dark:border-slate-600 dark:bg-slate-900 hover:bg-slate-50 dark:hover:bg-slate-900/50"
@click="toggleDropdown"
>
<template v-if="shouldShowDropdown" #dropdown>
@@ -26,7 +26,7 @@
/>
</template>
</filter-button>
<div class="flex items-center justify-end w-full gap-2">
<div class="flex items-center justify-end w-full gap-2 mt-2">
<woot-button
class="px-4 rounded-xl button clear outline-woot-200/50 outline"
@click.prevent="onClose"
@@ -0,0 +1,73 @@
<template>
<div
class="flex w-full"
:class="type === 'stateId' && shouldShowDropdown ? 'h-[150px]' : 'gap-2'"
>
<label class="w-full" :class="{ error: hasError }">
{{ label }}
<filter-button
right-icon="chevron-down"
:button-text="selectedItemName"
class="justify-between w-full h-[2.5rem] py-1.5 px-3 rounded-xl border border-slate-50 bg-slate-25 dark:border-slate-600 dark:bg-slate-900 hover:bg-slate-50 dark:hover:bg-slate-900/50"
@click="toggleDropdown"
>
<template v-if="shouldShowDropdown" #dropdown>
<filter-list-dropdown
v-on-clickaway="toggleDropdown"
:show-clear-filter="false"
:list-items="items"
:active-filter-id="selectedItemId"
:input-placeholder="placeholder"
enable-search
class="left-0 flex flex-col w-full overflow-y-auto h-fit !max-h-[160px] md:left-auto md:right-0 top-10"
@click="onSelect"
/>
</template>
</filter-button>
<span v-if="hasError" class="mt-1 message">{{ error }}</span>
</label>
</div>
</template>
<script setup>
import { ref, computed, defineComponent } from 'vue';
import FilterButton from 'dashboard/components/ui/Dropdown/DropdownButton.vue';
import FilterListDropdown from 'dashboard/components/ui/Dropdown/DropdownList.vue';
defineComponent({
name: 'SearchableDropdown',
});
const props = defineProps({
type: { type: String, required: true },
label: { type: String, default: null },
items: { type: Array, required: true },
value: { type: [Number, String], default: null },
placeholder: { type: String, default: null },
error: { type: String, default: null },
});
const emit = defineEmits(['change']);
const shouldShowDropdown = ref(false);
const toggleDropdown = () => {
shouldShowDropdown.value = !shouldShowDropdown.value;
};
const onSelect = item => {
emit('change', item, props.type);
toggleDropdown();
};
const hasError = computed(() => !!props.error);
const selectedItem = computed(() => {
if (!props.value) return null;
return props.items.find(i => i.id === props.value);
});
const selectedItemName = computed(
() => selectedItem.value?.name || props.placeholder
);
const selectedItemId = computed(() => selectedItem.value?.id || null);
</script>
@@ -0,0 +1,10 @@
export const ACCOUNT_PERMISSIONS = {
ACCOUNT_MANAGE: 'account_manage',
PORTAL_MANAGE: 'portal_manage',
CONVERSATION_MANAGE: 'conversation_manage',
CONVERSATION_UNASSIGNED_MANAGE: 'conversation_unassigned_manage',
CONVERSATION_PARTICIPATING_MANAGE: 'conversation_participating_manage',
MACROS_MANAGE: 'macros_manage',
REPORTS_MANAGE: 'reports_manage',
CONTACTS_MANAGE: 'contacts_manage',
};
@@ -121,7 +121,7 @@ export const generateConditionOptions = (options, key = 'id') => {
};
// Add the "None" option to the agent list
export const agentList = agents => [
export const addNoneToList = agents => [
{
id: 'nil',
name: 'None',
@@ -137,8 +137,8 @@ export const getActionOptions = ({
type,
}) => {
const actionsMap = {
assign_agent: agentList(agents),
assign_team: teams,
assign_agent: addNoneToList(agents),
assign_team: addNoneToList(teams),
send_email_to_team: teams,
add_label: generateConditionOptions(labels, 'title'),
remove_label: generateConditionOptions(labels, 'title'),
@@ -3,17 +3,14 @@ export const getCurrentAccount = ({ accounts } = {}, accountId) => {
return accounts.find(account => account.id === accountId);
};
// eslint-disable-next-line default-param-last
export const getUserRole = ({ accounts } = {}, accountId) => {
const currentAccount = getCurrentAccount({ accounts }, accountId) || {};
return currentAccount.role || null;
export const routeIsAccessibleFor = (route, userPermissions = []) => {
const { meta: { permissions: routePermissions = [] } = {} } = route;
return routePermissions.some(permissions =>
userPermissions.includes(permissions)
);
};
export const routeIsAccessibleFor = (route, role, roleWiseRoutes) => {
return roleWiseRoutes[role].includes(route);
};
const validateActiveAccountRoutes = (to, user, roleWiseRoutes) => {
const validateActiveAccountRoutes = (to, user) => {
// If the current account is active, then check for the route permissions
const accountDashboardURL = `accounts/${to.params.accountId}/dashboard`;
@@ -22,15 +19,13 @@ const validateActiveAccountRoutes = (to, user, roleWiseRoutes) => {
return accountDashboardURL;
}
const userRole = getUserRole(user, Number(to.params.accountId));
const isAccessible = routeIsAccessibleFor(to.name, userRole, roleWiseRoutes);
const isAccessible = routeIsAccessibleFor(to, user.permissions);
// If the route is not accessible for the user, return to dashboard screen
return isAccessible ? null : accountDashboardURL;
};
export const validateLoggedInRoutes = (to, user, roleWiseRoutes) => {
export const validateLoggedInRoutes = (to, user) => {
const currentAccount = getCurrentAccount(user, Number(to.params.accountId));
// If current account is missing, either user does not have
// access to the account or the account is deleted, return to login screen
if (!currentAccount) {
@@ -40,7 +35,7 @@ export const validateLoggedInRoutes = (to, user, roleWiseRoutes) => {
const isCurrentAccountActive = currentAccount.status === 'active';
if (isCurrentAccountActive) {
return validateActiveAccountRoutes(to, user, roleWiseRoutes);
return validateActiveAccountRoutes(to, user);
}
// If the current account is not active, then redirect the user to the suspended screen
@@ -367,8 +367,14 @@
"DESCRIPTION": "Click on the Sign in with Microsoft button to get started. You will redirected to the email sign in page. Once you accept the requested permissions, you would be redirected back to the inbox creation step.",
"EMAIL_PLACEHOLDER": "Enter email address",
"SIGN_IN": "Sign in with Microsoft",
"HELP": "To add your Microsoft account as a channel, you need to authenticate your Microsoft account by clicking on 'Sign in with Microsoft' ",
"ERROR_MESSAGE": "There was an error connecting to Microsoft, please try again"
},
"GOOGLE": {
"TITLE": "Google Email",
"DESCRIPTION": "Click on the Sign in with Google button to get started. You will redirected to the email sign in page. Once you accept the requested permissions, you would be redirected back to the inbox creation step.",
"SIGN_IN": "Sign in with Google",
"EMAIL_PLACEHOLDER": "Enter email address",
"ERROR_MESSAGE": "There was an error connecting to Google, please try again"
}
},
"DETAILS": {
@@ -736,6 +742,7 @@
},
"EMAIL_PROVIDERS": {
"MICROSOFT": "Microsoft",
"GOOGLE": "Google",
"OTHER_PROVIDERS": "Other Providers"
}
}
@@ -7,7 +7,14 @@ export const routes = [
{
path: frontendURL('accounts/:accountId/search'),
name: 'search',
roles: ['administrator', 'agent'],
meta: {
permissions: [
'account_manage',
'conversation_manage',
'conversation_participating_manage',
'conversation_unassigned_manage',
],
},
component: SearchView,
},
];
@@ -7,13 +7,27 @@ export const routes = [
{
path: frontendURL('accounts/:accountId/contacts'),
name: 'contacts_dashboard',
roles: ['administrator', 'agent'],
meta: {
permissions: [
'account_manage',
'conversation_manage',
'conversation_participating_manage',
'conversation_unassigned_manage',
],
},
component: ContactsView,
},
{
path: frontendURL('accounts/:accountId/contacts/custom_view/:id'),
name: 'contacts_segments_dashboard',
roles: ['administrator', 'agent'],
meta: {
permissions: [
'account_manage',
'conversation_manage',
'conversation_participating_manage',
'conversation_unassigned_manage',
],
},
component: ContactsView,
props: route => {
return { segmentsId: route.params.id };
@@ -22,7 +36,14 @@ export const routes = [
{
path: frontendURL('accounts/:accountId/labels/:label/contacts'),
name: 'contacts_labels_dashboard',
roles: ['administrator', 'agent'],
meta: {
permissions: [
'account_manage',
'conversation_manage',
'conversation_participating_manage',
'conversation_unassigned_manage',
],
},
component: ContactsView,
props: route => {
return { label: route.params.label };
@@ -31,7 +52,14 @@ export const routes = [
{
path: frontendURL('accounts/:accountId/contacts/:contactId'),
name: 'contact_profile_dashboard',
roles: ['administrator', 'agent'],
meta: {
permissions: [
'account_manage',
'conversation_manage',
'conversation_participating_manage',
'conversation_unassigned_manage',
],
},
component: ContactManageView,
props: route => {
return { contactId: route.params.contactId };
@@ -1,13 +1,21 @@
/* eslint arrow-body-style: 0 */
import { ACCOUNT_PERMISSIONS } from '../../../constants/permissions';
import { frontendURL } from '../../../helper/URLHelper';
const ConversationView = () => import('./ConversationView');
const GENERIC_CONVERSATION_PERMISSIONS = [
ACCOUNT_PERMISSIONS.ACCOUNT_MANAGE,
ACCOUNT_PERMISSIONS.CONVERSATION_MANAGE,
ACCOUNT_PERMISSIONS.CONVERSATION_PARTICIPATING_MANAGE,
ACCOUNT_PERMISSIONS.CONVERSATION_UNASSIGNED_MANAGE,
];
export default {
routes: [
{
path: frontendURL('accounts/:accountId/dashboard'),
name: 'home',
roles: ['administrator', 'agent'],
meta: { permissions: GENERIC_CONVERSATION_PERMISSIONS },
component: ConversationView,
props: () => {
return { inboxId: 0 };
@@ -16,7 +24,7 @@ export default {
{
path: frontendURL('accounts/:accountId/conversations/:conversation_id'),
name: 'inbox_conversation',
roles: ['administrator', 'agent'],
meta: { permissions: GENERIC_CONVERSATION_PERMISSIONS },
component: ConversationView,
props: route => {
return { inboxId: 0, conversationId: route.params.conversation_id };
@@ -25,7 +33,7 @@ export default {
{
path: frontendURL('accounts/:accountId/inbox/:inbox_id'),
name: 'inbox_dashboard',
roles: ['administrator', 'agent'],
meta: { permissions: GENERIC_CONVERSATION_PERMISSIONS },
component: ConversationView,
props: route => {
return { inboxId: route.params.inbox_id };
@@ -36,7 +44,7 @@ export default {
'accounts/:accountId/inbox/:inbox_id/conversations/:conversation_id'
),
name: 'conversation_through_inbox',
roles: ['administrator', 'agent'],
meta: { permissions: GENERIC_CONVERSATION_PERMISSIONS },
component: ConversationView,
props: route => {
return {
@@ -48,7 +56,7 @@ export default {
{
path: frontendURL('accounts/:accountId/label/:label'),
name: 'label_conversations',
roles: ['administrator', 'agent'],
meta: { permissions: GENERIC_CONVERSATION_PERMISSIONS },
component: ConversationView,
props: route => ({ label: route.params.label }),
},
@@ -57,7 +65,7 @@ export default {
'accounts/:accountId/label/:label/conversations/:conversation_id'
),
name: 'conversations_through_label',
roles: ['administrator', 'agent'],
meta: { permissions: GENERIC_CONVERSATION_PERMISSIONS },
component: ConversationView,
props: route => ({
conversationId: route.params.conversation_id,
@@ -67,7 +75,7 @@ export default {
{
path: frontendURL('accounts/:accountId/team/:teamId'),
name: 'team_conversations',
roles: ['administrator', 'agent'],
meta: { permissions: GENERIC_CONVERSATION_PERMISSIONS },
component: ConversationView,
props: route => ({ teamId: route.params.teamId }),
},
@@ -76,7 +84,7 @@ export default {
'accounts/:accountId/team/:teamId/conversations/:conversationId'
),
name: 'conversations_through_team',
roles: ['administrator', 'agent'],
meta: { permissions: GENERIC_CONVERSATION_PERMISSIONS },
component: ConversationView,
props: route => ({
conversationId: route.params.conversationId,
@@ -86,7 +94,7 @@ export default {
{
path: frontendURL('accounts/:accountId/custom_view/:id'),
name: 'folder_conversations',
roles: ['administrator', 'agent'],
meta: { permissions: GENERIC_CONVERSATION_PERMISSIONS },
component: ConversationView,
props: route => ({ foldersId: route.params.id }),
},
@@ -95,7 +103,7 @@ export default {
'accounts/:accountId/custom_view/:id/conversations/:conversation_id'
),
name: 'conversations_through_folders',
roles: ['administrator', 'agent'],
meta: { permissions: GENERIC_CONVERSATION_PERMISSIONS },
component: ConversationView,
props: route => ({
conversationId: route.params.conversation_id,
@@ -105,7 +113,7 @@ export default {
{
path: frontendURL('accounts/:accountId/mentions/conversations'),
name: 'conversation_mentions',
roles: ['administrator', 'agent'],
meta: { permissions: GENERIC_CONVERSATION_PERMISSIONS },
component: ConversationView,
props: () => ({ conversationType: 'mention' }),
},
@@ -114,7 +122,7 @@ export default {
'accounts/:accountId/mentions/conversations/:conversationId'
),
name: 'conversation_through_mentions',
roles: ['administrator', 'agent'],
meta: { permissions: GENERIC_CONVERSATION_PERMISSIONS },
component: ConversationView,
props: route => ({
conversationId: route.params.conversationId,
@@ -124,7 +132,7 @@ export default {
{
path: frontendURL('accounts/:accountId/unattended/conversations'),
name: 'conversation_unattended',
roles: ['administrator', 'agent'],
meta: { permissions: GENERIC_CONVERSATION_PERMISSIONS },
component: ConversationView,
props: () => ({ conversationType: 'unattended' }),
},
@@ -133,7 +141,7 @@ export default {
'accounts/:accountId/unattended/conversations/:conversationId'
),
name: 'conversation_through_unattended',
roles: ['administrator', 'agent'],
meta: { permissions: GENERIC_CONVERSATION_PERMISSIONS },
component: ConversationView,
props: route => ({
conversationId: route.params.conversationId,
@@ -143,8 +151,13 @@ export default {
{
path: frontendURL('accounts/:accountId/participating/conversations'),
name: 'conversation_participating',
roles: ['administrator', 'agent'],
component: ConversationView,
meta: {
permissions: [
ACCOUNT_PERMISSIONS.CONVERSATION_MANAGE,
ACCOUNT_PERMISSIONS.CONVERSATION_PARTICIPATING_MANAGE,
],
},
props: () => ({ conversationType: 'participating' }),
},
{
@@ -152,8 +165,13 @@ export default {
'accounts/:accountId/participating/conversations/:conversationId'
),
name: 'conversation_through_participating',
roles: ['administrator', 'agent'],
component: ConversationView,
meta: {
permissions: [
ACCOUNT_PERMISSIONS.CONVERSATION_MANAGE,
ACCOUNT_PERMISSIONS.CONVERSATION_PARTICIPATING_MANAGE,
],
},
props: route => ({
conversationId: route.params.conversationId,
conversationType: 'participating',
@@ -28,7 +28,9 @@ export default {
{
path: frontendURL('accounts/:accountId/suspended'),
name: 'account_suspended',
roles: ['administrator', 'agent'],
meta: {
permissions: ['profile_manage'],
},
component: Suspended,
},
],
@@ -30,13 +30,27 @@ const portalRoutes = [
{
path: getPortalRoute(''),
name: 'default_portal_articles',
roles: ['administrator', 'agent'],
meta: {
permissions: [
'account_manage',
'conversation_manage',
'conversation_participating_manage',
'conversation_unassigned_manage',
],
},
component: DefaultPortalArticles,
},
{
path: getPortalRoute('all'),
name: 'list_all_portals',
roles: ['administrator', 'agent'],
meta: {
permissions: [
'account_manage',
'conversation_manage',
'conversation_participating_manage',
'conversation_unassigned_manage',
],
},
component: ListAllPortals,
},
{
@@ -47,55 +61,88 @@ const portalRoutes = [
path: '',
name: 'new_portal_information',
component: PortalDetails,
roles: ['administrator'],
meta: {
permissions: ['account_manage'],
},
},
{
path: ':portalSlug/customization',
name: 'portal_customization',
component: PortalCustomization,
roles: ['administrator'],
meta: {
permissions: ['account_manage'],
},
},
{
path: ':portalSlug/finish',
name: 'portal_finish',
component: PortalSettingsFinish,
roles: ['administrator'],
meta: {
permissions: ['account_manage'],
},
},
],
},
{
path: getPortalRoute(':portalSlug'),
name: 'portalSlug',
roles: ['administrator', 'agent'],
meta: {
permissions: [
'account_manage',
'conversation_manage',
'conversation_participating_manage',
'conversation_unassigned_manage',
],
},
component: ShowPortal,
},
{
path: getPortalRoute(':portalSlug/edit'),
roles: ['administrator', 'agent'],
meta: {
permissions: [
'account_manage',
'conversation_manage',
'conversation_participating_manage',
'conversation_unassigned_manage',
],
},
component: EditPortal,
children: [
{
path: '',
name: 'edit_portal_information',
component: EditPortalBasic,
roles: ['administrator'],
meta: {
permissions: ['account_manage'],
},
},
{
path: 'customizations',
name: 'edit_portal_customization',
component: EditPortalCustomization,
roles: ['administrator'],
meta: {
permissions: ['account_manage'],
},
},
{
path: 'locales',
name: 'edit_portal_locales',
component: EditPortalLocales,
roles: ['administrator'],
meta: {
permissions: ['account_manage'],
},
},
{
path: 'categories',
name: 'list_all_locale_categories',
roles: ['administrator', 'agent'],
meta: {
permissions: [
'account_manage',
'conversation_manage',
'conversation_participating_manage',
'conversation_unassigned_manage',
],
},
component: ListAllCategories,
},
],
@@ -106,39 +153,81 @@ const articleRoutes = [
{
path: getPortalRoute(':portalSlug/:locale/articles'),
name: 'list_all_locale_articles',
roles: ['administrator', 'agent'],
meta: {
permissions: [
'account_manage',
'conversation_manage',
'conversation_participating_manage',
'conversation_unassigned_manage',
],
},
component: ListAllArticles,
},
{
path: getPortalRoute(':portalSlug/:locale/articles/new'),
name: 'new_article',
roles: ['administrator', 'agent'],
meta: {
permissions: [
'account_manage',
'conversation_manage',
'conversation_participating_manage',
'conversation_unassigned_manage',
],
},
component: NewArticle,
},
{
path: getPortalRoute(':portalSlug/:locale/articles/mine'),
name: 'list_mine_articles',
roles: ['administrator', 'agent'],
meta: {
permissions: [
'account_manage',
'conversation_manage',
'conversation_participating_manage',
'conversation_unassigned_manage',
],
},
component: ListAllArticles,
},
{
path: getPortalRoute(':portalSlug/:locale/articles/archived'),
name: 'list_archived_articles',
roles: ['administrator', 'agent'],
meta: {
permissions: [
'account_manage',
'conversation_manage',
'conversation_participating_manage',
'conversation_unassigned_manage',
],
},
component: ListAllArticles,
},
{
path: getPortalRoute(':portalSlug/:locale/articles/draft'),
name: 'list_draft_articles',
roles: ['administrator', 'agent'],
meta: {
permissions: [
'account_manage',
'conversation_manage',
'conversation_participating_manage',
'conversation_unassigned_manage',
],
},
component: ListAllArticles,
},
{
path: getPortalRoute(':portalSlug/:locale/articles/:articleSlug'),
name: 'edit_article',
roles: ['administrator', 'agent'],
meta: {
permissions: [
'account_manage',
'conversation_manage',
'conversation_participating_manage',
'conversation_unassigned_manage',
],
},
component: EditArticle,
},
];
@@ -147,19 +236,40 @@ const categoryRoutes = [
{
path: getPortalRoute(':portalSlug/:locale/categories'),
name: 'all_locale_categories',
roles: ['administrator', 'agent'],
meta: {
permissions: [
'account_manage',
'conversation_manage',
'conversation_participating_manage',
'conversation_unassigned_manage',
],
},
component: ListAllCategories,
},
{
path: getPortalRoute(':portalSlug/:locale/categories/new'),
name: 'new_category_in_locale',
roles: ['administrator', 'agent'],
meta: {
permissions: [
'account_manage',
'conversation_manage',
'conversation_participating_manage',
'conversation_unassigned_manage',
],
},
component: NewCategory,
},
{
path: getPortalRoute(':portalSlug/:locale/categories/:categorySlug'),
name: 'show_category',
roles: ['administrator', 'agent'],
meta: {
permissions: [
'account_manage',
'conversation_manage',
'conversation_participating_manage',
'conversation_unassigned_manage',
],
},
component: ListAllArticles,
},
{
@@ -167,13 +277,27 @@ const categoryRoutes = [
':portalSlug/:locale/categories/:categorySlug/articles'
),
name: 'show_category_articles',
roles: ['administrator', 'agent'],
meta: {
permissions: [
'account_manage',
'conversation_manage',
'conversation_participating_manage',
'conversation_unassigned_manage',
],
},
component: ListCategoryArticles,
},
{
path: getPortalRoute(':portalSlug/:locale/categories/:categorySlug'),
name: 'edit_category',
roles: ['administrator', 'agent'],
meta: {
permissions: [
'account_manage',
'conversation_manage',
'conversation_participating_manage',
'conversation_unassigned_manage',
],
},
component: EditCategory,
},
];
@@ -12,13 +12,27 @@ export const routes = [
path: '',
name: 'inbox_view',
component: InboxEmptyStateView,
roles: ['administrator', 'agent'],
meta: {
permissions: [
'account_manage',
'conversation_manage',
'conversation_participating_manage',
'conversation_unassigned_manage',
],
},
},
{
path: ':notification_id',
name: 'inbox_view_conversation',
component: InboxDetailView,
roles: ['administrator', 'agent'],
meta: {
permissions: [
'account_manage',
'conversation_manage',
'conversation_participating_manage',
'conversation_unassigned_manage',
],
},
},
],
},
@@ -18,7 +18,14 @@ export const routes = [
path: '',
name: 'notifications_index',
component: NotificationsView,
roles: ['administrator', 'agent'],
meta: {
permissions: [
'account_manage',
'conversation_manage',
'conversation_participating_manage',
'conversation_unassigned_manage',
],
},
},
],
},
@@ -6,7 +6,9 @@ export default {
routes: [
{
path: frontendURL('accounts/:accountId/settings/general'),
roles: ['administrator'],
meta: {
permissions: ['account_manage'],
},
component: SettingsContent,
props: {
headerTitle: 'GENERAL_SETTINGS.TITLE',
@@ -18,7 +20,9 @@ export default {
path: '',
name: 'general_settings_index',
component: Index,
roles: ['administrator'],
meta: {
permissions: ['account_manage'],
},
},
],
},
@@ -8,7 +8,9 @@ export default {
routes: [
{
path: frontendURL('accounts/:accountId/settings/agent-bots'),
roles: ['administrator'],
meta: {
permissions: ['account_manage'],
},
component: SettingsContent,
props: {
headerTitle: 'AGENT_BOTS.HEADER',
@@ -20,19 +22,25 @@ export default {
path: '',
name: 'agent_bots',
component: Bot,
roles: ['administrator'],
meta: {
permissions: ['account_manage'],
},
},
{
path: 'csml/new',
name: 'agent_bots_csml_new',
component: CsmlNewBot,
roles: ['administrator'],
meta: {
permissions: ['account_manage'],
},
},
{
path: 'csml/:botId',
name: 'agent_bots_csml_edit',
component: CsmlEditBot,
roles: ['administrator'],
meta: {
permissions: ['account_manage'],
},
},
],
},
@@ -22,7 +22,9 @@ export default {
path: 'list',
name: 'agent_list',
component: AgentHome,
roles: ['administrator'],
meta: {
permissions: ['account_manage'],
},
},
],
},
@@ -22,7 +22,9 @@ export default {
path: 'list',
name: 'attributes_list',
component: AttributesHome,
roles: ['administrator'],
meta: {
permissions: ['account_manage'],
},
},
],
},
@@ -22,7 +22,9 @@ export default {
{
path: 'list',
name: 'auditlogs_list',
roles: ['administrator'],
meta: {
permissions: ['account_manage'],
},
component: AuditLogsHome,
},
],
@@ -22,7 +22,9 @@ export default {
path: 'list',
name: 'automation_list',
component: Automation,
roles: ['administrator'],
meta: {
permissions: ['account_manage'],
},
},
],
},
@@ -6,7 +6,9 @@ export default {
routes: [
{
path: frontendURL('accounts/:accountId/settings/billing'),
roles: ['administrator'],
meta: {
permissions: ['account_manage'],
},
component: SettingsContent,
props: {
headerTitle: 'BILLING_SETTINGS.TITLE',
@@ -18,7 +20,9 @@ export default {
path: '',
name: 'billing_settings_index',
component: Index,
roles: ['administrator'],
meta: {
permissions: ['account_manage'],
},
},
],
},
@@ -19,7 +19,9 @@ export default {
{
path: 'ongoing',
name: 'ongoing_campaigns',
roles: ['administrator'],
meta: {
permissions: ['account_manage'],
},
component: Index,
},
],
@@ -35,7 +37,9 @@ export default {
{
path: 'one_off',
name: 'one_off',
roles: ['administrator'],
meta: {
permissions: ['account_manage'],
},
component: Index,
},
],
@@ -22,7 +22,14 @@ export default {
{
path: 'list',
name: 'canned_list',
roles: ['administrator', 'agent'],
meta: {
permissions: [
'account_manage',
'conversation_manage',
'conversation_participating_manage',
'conversation_unassigned_manage',
],
},
component: CannedHome,
},
],
@@ -541,7 +541,8 @@ export default {
this.isALineChannel ||
this.isAPIInbox ||
(this.isAnEmailChannel && !this.inbox.provider) ||
(this.isAnEmailChannel && this.inbox.provider === 'microsoft') ||
this.isAMicrosoftInbox ||
this.isAGoogleInbox ||
this.isAWhatsAppChannel ||
this.isAWebWidgetInbox
) {
@@ -8,7 +8,7 @@
:header-title="$t('INBOX_MGMT.ADD.EMAIL_PROVIDER.TITLE')"
:header-content="$t('INBOX_MGMT.ADD.EMAIL_PROVIDER.DESCRIPTION')"
/>
<div class="grid grid-cols-4 max-w-3xl mx-0 mt-6">
<div class="grid max-w-3xl grid-cols-4 mx-0 mt-6">
<channel-selector
v-for="emailProvider in emailProviderList"
:key="emailProvider.key"
@@ -20,58 +20,59 @@
</div>
</div>
<microsoft v-else-if="provider === 'microsoft'" />
<google v-else-if="provider === 'google'" />
<forward-to-option v-else-if="provider === 'other_provider'" />
</template>
<script>
<script setup>
import { ref, computed } from 'vue';
import ForwardToOption from './emailChannels/ForwardToOption.vue';
import Microsoft from './emailChannels/Microsoft.vue';
import { mapGetters } from 'vuex';
import Google from './emailChannels/Google.vue';
import ChannelSelector from 'dashboard/components/ChannelSelector.vue';
import PageHeader from '../../SettingsSubPageHeader.vue';
export default {
components: {
ChannelSelector,
ForwardToOption,
Microsoft,
PageHeader,
},
data() {
return { provider: '' };
},
computed: {
...mapGetters({
globalConfig: 'globalConfig/get',
isAChatwootInstance: 'globalConfig/isAChatwootInstance',
}),
emailProviderList() {
return [
{
title: this.$t('INBOX_MGMT.EMAIL_PROVIDERS.MICROSOFT'),
isEnabled: !!this.globalConfig.azureAppId,
key: 'microsoft',
src: '/assets/images/dashboard/channels/microsoft.png',
},
{
title: this.$t('INBOX_MGMT.EMAIL_PROVIDERS.OTHER_PROVIDERS'),
isEnabled: true,
key: 'other_provider',
src: '/assets/images/dashboard/channels/email.png',
},
].filter(provider => {
if (this.isAChatwootInstance) {
return true;
}
return provider.isEnabled;
});
import { useStoreGetters } from 'dashboard/composables/store';
import { useI18n } from 'dashboard/composables/useI18n';
const provider = ref('');
const getters = useStoreGetters();
const { t } = useI18n();
const globalConfig = getters['globalConfig/get'];
const isAChatwootInstance = getters['globalConfig/isAChatwootInstance'];
const emailProviderList = computed(() => {
return [
{
title: t('INBOX_MGMT.EMAIL_PROVIDERS.MICROSOFT'),
isEnabled: !!globalConfig.value.azureAppId,
key: 'microsoft',
src: '/assets/images/dashboard/channels/microsoft.png',
},
},
methods: {
onClick(emailProvider) {
if (emailProvider.isEnabled) {
this.provider = emailProvider.key;
}
{
title: t('INBOX_MGMT.EMAIL_PROVIDERS.GOOGLE'),
isEnabled: !!window.chatwootConfig.googleOAuthClientId,
key: 'google',
src: '/assets/images/dashboard/channels/google.png',
},
},
};
{
title: t('INBOX_MGMT.EMAIL_PROVIDERS.OTHER_PROVIDERS'),
isEnabled: true,
key: 'other_provider',
src: '/assets/images/dashboard/channels/email.png',
},
].filter(providerConfig => {
if (isAChatwootInstance.value) {
return true;
}
return providerConfig.isEnabled;
});
});
function onClick(emailProvider) {
if (emailProvider.isEnabled) {
this.provider = emailProvider.key;
}
}
</script>
@@ -0,0 +1,18 @@
<script setup>
import OAuthChannel from './OAuthChannel.vue';
import { defineComponent } from 'vue';
defineComponent({
name: 'GoogleOAuthChannel',
});
</script>
<template>
<OAuthChannel
provider="google"
:title="$t('INBOX_MGMT.ADD.GOOGLE.TITLE')"
:description="$t('INBOX_MGMT.ADD.GOOGLE.DESCRIPTION')"
:input-placeholder="$t('INBOX_MGMT.ADD.GOOGLE.EMAIL_PLACEHOLDER')"
:submit-button-text="$t('INBOX_MGMT.ADD.GOOGLE.SIGN_IN')"
:error-message="$t('INBOX_MGMT.ADD.GOOGLE.ERROR_MESSAGE')"
/>
</template>
@@ -1,71 +1,19 @@
<template>
<div
class="border border-slate-25 dark:border-slate-800/60 bg-white dark:bg-slate-900 h-full p-6 w-full max-w-full md:w-3/4 md:max-w-[75%] flex-shrink-0 flex-grow-0"
>
<settings-sub-page-header
:header-title="$t('INBOX_MGMT.ADD.MICROSOFT.TITLE')"
:header-content="$t('INBOX_MGMT.ADD.MICROSOFT.DESCRIPTION')"
/>
<form
class="microsoft--sign-in-form"
@submit.prevent="requestAuthorization"
>
<woot-input
v-model.trim="email"
type="text"
:placeholder="$t('INBOX_MGMT.ADD.MICROSOFT.EMAIL_PLACEHOLDER')"
@blur="$v.email.$touch"
/>
<woot-submit-button
icon="brand-twitter"
type="submit"
:button-text="$t('INBOX_MGMT.ADD.MICROSOFT.SIGN_IN')"
:loading="isRequestingAuthorization"
/>
</form>
</div>
</template>
<script>
import alertMixin from 'shared/mixins/alertMixin';
import microsoftClient from 'dashboard/api/channel/microsoftClient';
import SettingsSubPageHeader from '../../../SettingsSubPageHeader.vue';
import { required, email } from 'vuelidate/lib/validators';
<script setup>
import OAuthChannel from './OAuthChannel.vue';
import { defineComponent } from 'vue';
export default {
components: { SettingsSubPageHeader },
mixins: [alertMixin],
data() {
return { email: '', isRequestingAuthorization: false };
},
validations: {
email: { required, email },
},
methods: {
async requestAuthorization() {
try {
this.$v.$touch();
if (this.$v.$invalid) return;
this.isRequestingAuthorization = true;
const response = await microsoftClient.generateAuthorization({
email: this.email,
});
const {
data: { url },
} = response;
window.location.href = url;
} catch (error) {
this.showAlert(this.$t('INBOX_MGMT.ADD.MICROSOFT.ERROR_MESSAGE'));
} finally {
this.isRequestingAuthorization = false;
}
},
},
};
defineComponent({
name: 'MicrosoftOAuthChannel',
});
</script>
<style scoped>
.microsoft--sign-in-form {
@apply mt-6;
}
</style>
<template>
<OAuthChannel
provider="microsoft"
:title="$t('INBOX_MGMT.ADD.MICROSOFT.TITLE')"
:description="$t('INBOX_MGMT.ADD.MICROSOFT.DESCRIPTION')"
:input-placeholder="$t('INBOX_MGMT.ADD.MICROSOFT.EMAIL_PLACEHOLDER')"
:submit-button-text="$t('INBOX_MGMT.ADD.MICROSOFT.SIGN_IN')"
:error-message="$t('INBOX_MGMT.ADD.MICROSOFT.ERROR_MESSAGE')"
/>
</template>
@@ -0,0 +1,89 @@
<script setup>
import { ref, computed } from 'vue';
import microsoftClient from 'dashboard/api/channel/microsoftClient';
import googleClient from 'dashboard/api/channel/googleClient';
import SettingsSubPageHeader from '../../../SettingsSubPageHeader.vue';
import { useAlert } from 'dashboard/composables';
const props = defineProps({
provider: {
type: String,
required: true,
validate: value => ['microsoft', 'google'].includes(value),
},
title: {
type: String,
required: true,
},
description: {
type: String,
required: true,
},
submitButtonText: {
type: String,
required: true,
},
errorMessage: {
type: String,
required: true,
},
inputPlaceholder: {
type: String,
required: true,
},
});
const isRequestingAuthorization = ref(false);
const email = ref('');
const client = computed(() => {
if (props.provider === 'microsoft') {
return microsoftClient;
}
return googleClient;
});
async function requestAuthorization() {
try {
isRequestingAuthorization.value = true;
const response = await client.value.generateAuthorization({
email: email.value,
});
const {
data: { url },
} = response;
window.location.href = url;
} catch (error) {
useAlert(props.errorMessage);
} finally {
isRequestingAuthorization.value = false;
}
}
</script>
<template>
<div
class="border border-slate-25 dark:border-slate-800/60 bg-white dark:bg-slate-900 h-full p-6 w-full max-w-full md:w-3/4 md:max-w-[75%] flex-shrink-0 flex-grow-0"
>
<settings-sub-page-header
:header-title="title"
:header-content="description"
/>
<form class="mt-6" @submit.prevent="requestAuthorization">
<woot-input
v-model="email"
type="email"
:placeholder="inputPlaceholder"
/>
<woot-submit-button
icon="brand-twitter"
type="submit"
:button-text="submitButtonText"
:loading="isRequestingAuthorization"
/>
</form>
</div>
</template>
@@ -35,7 +35,9 @@ export default {
path: 'list',
name: 'settings_inbox_list',
component: InboxHome,
roles: ['administrator'],
meta: {
permissions: ['account_manage'],
},
},
{
path: 'new',
@@ -45,19 +47,25 @@ export default {
path: '',
name: 'settings_inbox_new',
component: ChannelList,
roles: ['administrator'],
meta: {
permissions: ['account_manage'],
},
},
{
path: ':inbox_id/finish',
name: 'settings_inbox_finish',
component: FinishSetup,
roles: ['administrator'],
meta: {
permissions: ['account_manage'],
},
},
{
path: ':sub_page',
name: 'settings_inboxes_page_channel',
component: channelFactory.create(),
roles: ['administrator'],
meta: {
permissions: ['account_manage'],
},
props: route => {
return { channel_name: route.params.sub_page };
},
@@ -65,7 +73,9 @@ export default {
{
path: ':inbox_id/agents',
name: 'settings_inboxes_add_agents',
roles: ['administrator'],
meta: {
permissions: ['account_manage'],
},
component: AddAgents,
},
],
@@ -74,7 +84,9 @@ export default {
path: ':inboxId',
name: 'settings_inbox_show',
component: Settings,
roles: ['administrator'],
meta: {
permissions: ['account_manage'],
},
},
],
},
@@ -26,13 +26,17 @@ export default {
path: '',
name: 'settings_applications',
component: Index,
roles: ['administrator'],
meta: {
permissions: ['account_manage'],
},
},
{
path: ':integration_id',
name: 'settings_applications_integration',
component: IntegrationHooks,
roles: ['administrator'],
meta: {
permissions: ['account_manage'],
},
props: route => ({
integrationId: route.params.integration_id,
}),
@@ -30,32 +30,42 @@ export default {
path: '',
name: 'settings_integrations',
component: Index,
roles: ['administrator'],
meta: {
permissions: ['account_manage'],
},
},
{
path: 'webhook',
component: Webhook,
name: 'settings_integrations_webhook',
roles: ['administrator'],
meta: {
permissions: ['account_manage'],
},
},
{
path: 'dashboard-apps',
component: DashboardApps,
name: 'settings_integrations_dashboard_apps',
roles: ['administrator'],
meta: {
permissions: ['account_manage'],
},
},
{
path: 'slack',
name: 'settings_integrations_slack',
component: Slack,
roles: ['administrator'],
meta: {
permissions: ['account_manage'],
},
props: route => ({ code: route.query.code }),
},
{
path: ':integration_id',
name: 'settings_integrations_integration',
component: ShowIntegration,
roles: ['administrator'],
meta: {
permissions: ['account_manage'],
},
props: route => {
return {
integrationId: route.params.integration_id,
@@ -17,13 +17,17 @@ export default {
{
path: '',
name: 'labels_wrapper',
roles: ['administrator'],
meta: {
permissions: ['account_manage'],
},
redirect: 'list',
},
{
path: 'list',
name: 'labels_list',
roles: ['administrator'],
meta: {
permissions: ['account_manage'],
},
component: Index,
},
],
@@ -23,19 +23,40 @@ export default {
path: '',
name: 'macros_wrapper',
component: Macros,
roles: ['administrator', 'agent'],
meta: {
permissions: [
'account_manage',
'conversation_manage',
'conversation_participating_manage',
'conversation_unassigned_manage',
],
},
},
{
path: 'new',
name: 'macros_new',
component: MacroEditor,
roles: ['administrator', 'agent'],
meta: {
permissions: [
'account_manage',
'conversation_manage',
'conversation_participating_manage',
'conversation_unassigned_manage',
],
},
},
{
path: ':macroId/edit',
name: 'macros_edit',
component: MacroEditor,
roles: ['administrator', 'agent'],
meta: {
permissions: [
'account_manage',
'conversation_manage',
'conversation_participating_manage',
'conversation_unassigned_manage',
],
},
},
],
},
@@ -8,14 +8,28 @@ export default {
{
path: frontendURL('accounts/:accountId/profile'),
name: 'profile_settings',
roles: ['administrator', 'agent'],
meta: {
permissions: [
'account_manage',
'conversation_manage',
'conversation_participating_manage',
'conversation_unassigned_manage',
],
},
component: SettingsContent,
children: [
{
path: 'settings',
name: 'profile_settings_index',
component: Index,
roles: ['administrator', 'agent'],
meta: {
permissions: [
'account_manage',
'conversation_manage',
'conversation_participating_manage',
'conversation_unassigned_manage',
],
},
},
],
},
@@ -29,7 +29,9 @@ export default {
{
path: 'overview',
name: 'account_overview_reports',
roles: ['administrator'],
meta: {
permissions: ['account_manage'],
},
component: LiveReports,
},
],
@@ -46,7 +48,9 @@ export default {
{
path: 'conversation',
name: 'conversation_reports',
roles: ['administrator'],
meta: {
permissions: ['account_manage'],
},
component: Index,
},
],
@@ -63,7 +67,9 @@ export default {
{
path: 'csat',
name: 'csat_reports',
roles: ['administrator'],
meta: {
permissions: ['account_manage'],
},
component: CsatResponses,
},
],
@@ -80,7 +86,9 @@ export default {
{
path: 'bot',
name: 'bot_reports',
roles: ['administrator'],
meta: {
permissions: ['account_manage'],
},
component: BotReports,
},
],
@@ -97,7 +105,9 @@ export default {
{
path: 'agent',
name: 'agent_reports',
roles: ['administrator'],
meta: {
permissions: ['account_manage'],
},
component: AgentReports,
},
],
@@ -114,7 +124,9 @@ export default {
{
path: 'label',
name: 'label_reports',
roles: ['administrator'],
meta: {
permissions: ['account_manage'],
},
component: LabelReports,
},
],
@@ -131,7 +143,9 @@ export default {
{
path: 'inboxes',
name: 'inbox_reports',
roles: ['administrator'],
meta: {
permissions: ['account_manage'],
},
component: InboxReports,
},
],
@@ -147,7 +161,9 @@ export default {
{
path: 'teams',
name: 'team_reports',
roles: ['administrator'],
meta: {
permissions: ['account_manage'],
},
component: TeamReports,
},
],
@@ -164,7 +180,9 @@ export default {
{
path: 'sla',
name: 'sla_reports',
roles: ['administrator'],
meta: {
permissions: ['account_manage'],
},
component: SLAReports,
},
],
@@ -24,7 +24,14 @@ export default {
{
path: frontendURL('accounts/:accountId/settings'),
name: 'settings_home',
roles: ['administrator', 'agent'],
meta: {
permissions: [
'account_manage',
'conversation_manage',
'conversation_participating_manage',
'conversation_unassigned_manage',
],
},
redirect: () => {
if (store.getters.getCurrentRole === 'administrator') {
return frontendURL('accounts/:accountId/settings/general');
@@ -13,13 +13,17 @@ export default {
{
path: '',
name: 'sla_wrapper',
roles: ['administrator'],
meta: {
permissions: ['account_manage'],
},
redirect: 'list',
},
{
path: 'list',
name: 'sla_list',
roles: ['administrator'],
meta: {
permissions: ['account_manage'],
},
component: Index,
},
],
@@ -36,7 +36,9 @@ export default {
path: 'list',
name: 'settings_teams_list',
component: TeamsHome,
roles: ['administrator'],
meta: {
permissions: ['account_manage'],
},
},
{
path: 'new',
@@ -46,18 +48,24 @@ export default {
path: '',
name: 'settings_teams_new',
component: CreateTeam,
roles: ['administrator'],
meta: {
permissions: ['account_manage'],
},
},
{
path: ':teamId/finish',
name: 'settings_teams_finish',
component: FinishSetup,
roles: ['administrator'],
meta: {
permissions: ['account_manage'],
},
},
{
path: ':teamId/agents',
name: 'settings_teams_add_agents',
roles: ['administrator'],
meta: {
permissions: ['account_manage'],
},
component: AddAgents,
},
],
@@ -70,18 +78,24 @@ export default {
path: '',
name: 'settings_teams_edit',
component: EditTeam,
roles: ['administrator'],
meta: {
permissions: ['account_manage'],
},
},
{
path: 'agents',
name: 'settings_teams_edit_members',
component: EditAgents,
roles: ['administrator'],
meta: {
permissions: ['account_manage'],
},
},
{
path: 'finish',
name: 'settings_teams_edit_finish',
roles: ['administrator'],
meta: {
permissions: ['account_manage'],
},
component: FinishSetup,
},
],
@@ -1,24 +1,30 @@
<script setup>
import EmptyState from 'dashboard/components/widgets/EmptyState.vue';
import { onMounted } from 'vue';
const toggleSupportWidgetVisibility = () => {
if (window.$chatwoot) {
window.$chatwoot.toggleBubbleVisibility('show');
}
};
const setupListenerForWidgetEvent = () => {
window.addEventListener('chatwoot:on-message', () => {
toggleSupportWidgetVisibility();
});
};
onMounted(() => {
toggleSupportWidgetVisibility();
setupListenerForWidgetEvent();
});
</script>
<template>
<div class="suspended-page">
<div class="items-center bg-slate-50 flex justify-center h-full w-full">
<empty-state
:title="$t('APP_GLOBAL.ACCOUNT_SUSPENDED.TITLE')"
:message="$t('APP_GLOBAL.ACCOUNT_SUSPENDED.MESSAGE')"
/>
</div>
</template>
<script>
import EmptyState from 'dashboard/components/widgets/EmptyState.vue';
export default {
components: { EmptyState },
};
</script>
<style scoped>
.suspended-page {
align-items: center;
background: var(--s-50);
display: flex;
justify-content: center;
height: 100%;
width: 100%;
}
</style>
+1 -28
View File
@@ -8,29 +8,6 @@ import AnalyticsHelper from '../helper/AnalyticsHelper';
const routes = [...dashboard.routes];
window.roleWiseRoutes = {
agent: [],
administrator: [],
};
// generateRoleWiseRoute - updates window object with agent/admin route
const generateRoleWiseRoute = route => {
route.forEach(element => {
if (element.children) {
generateRoleWiseRoute(element.children);
}
if (element.roles) {
element.roles.forEach(roleEl => {
window.roleWiseRoutes[roleEl].push(element.name);
});
}
});
};
// Create a object of routes
// accessible by each role.
// returns an object with roles as keys and routeArr as values
generateRoleWiseRoute(routes);
export const router = new VueRouter({ mode: 'history', routes });
export const validateAuthenticateRoutePermission = (to, next, { getters }) => {
@@ -45,11 +22,7 @@ export const validateAuthenticateRoutePermission = (to, next, { getters }) => {
return next(frontendURL(`accounts/${user.account_id}/dashboard`));
}
const nextRoute = validateLoggedInRoutes(
to,
getters.getCurrentUser,
window.roleWiseRoutes
);
const nextRoute = validateLoggedInRoutes(to, getters.getCurrentUser);
return nextRoute ? next(frontendURL(nextRoute)) : next();
};
+1
View File
@@ -62,6 +62,7 @@ const runSDK = ({ baseUrl, websiteToken }) => {
type: getBubbleView(chatwootSettings.type),
launcherTitle: chatwootSettings.launcherTitle || '',
showPopoutButton: chatwootSettings.showPopoutButton || false,
showUnreadMessagesDialog: chatwootSettings.showUnreadMessagesDialog ?? true,
widgetStyle: getWidgetStyle(chatwootSettings.widgetStyle) || 'standard',
resetTriggered: false,
darkMode: getDarkMode(chatwootSettings.darkMode),
+2 -1
View File
@@ -14,6 +14,7 @@ import App from '../v3/App.vue';
import router, { initalizeRouter } from '../v3/views/index';
import store from '../v3/store';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon';
import { emitter } from '../shared/helpers/mitt';
Vue.config.env = process.env;
@@ -45,11 +46,11 @@ Vue.use(VueRouter);
Vue.use(VueI18n);
Vue.use(Vuelidate);
Vue.use(AnalyticsPlugin);
Vue.prototype.$emitter = emitter;
Vue.component('fluent-icon', FluentIcon);
const i18nConfig = new VueI18n({ locale: 'en', messages: i18n });
window.bus = new Vue();
initializeChatwootEvents();
initializeAnalyticsEvents();
initalizeRouter();
+1
View File
@@ -160,6 +160,7 @@ export const IFrameHelper = {
showPopoutButton: window.$chatwoot.showPopoutButton,
widgetStyle: window.$chatwoot.widgetStyle,
darkMode: window.$chatwoot.darkMode,
showUnreadMessagesDialog: window.$chatwoot.showUnreadMessagesDialog,
campaignsSnoozedTill,
});
IFrameHelper.onLoad({
@@ -47,6 +47,9 @@ export default {
isAMicrosoftInbox() {
return this.isAnEmailChannel && this.inbox.provider === 'microsoft';
},
isAGoogleInbox() {
return this.isAnEmailChannel && this.inbox.provider === 'google';
},
isAPIInbox() {
return this.channelType === INBOX_TYPES.API;
},
@@ -1,18 +1,18 @@
<template>
<div
class="flex flex-col bg-woot-25 min-h-full w-full py-12 sm:px-6 lg:px-8 justify-center dark:bg-slate-900"
class="flex flex-col justify-center w-full min-h-full py-12 bg-woot-25 sm:px-6 lg:px-8 dark:bg-slate-900"
>
<form
class="sm:mx-auto sm:w-full sm:max-w-lg bg-white dark:bg-slate-800 p-11 shadow sm:shadow-lg sm:rounded-lg"
class="bg-white shadow sm:mx-auto sm:w-full sm:max-w-lg dark:bg-slate-800 p-11 sm:shadow-lg sm:rounded-lg"
@submit.prevent="submit"
>
<h1
class="mb-1 text-left text-2xl font-medium tracking-tight text-slate-900 dark:text-white"
class="mb-1 text-2xl font-medium tracking-tight text-left text-slate-900 dark:text-white"
>
{{ $t('RESET_PASSWORD.TITLE') }}
</h1>
<p
class="text-sm text-slate-600 dark:text-woot-50 tracking-normal font-normal leading-6 mb-4"
class="mb-4 text-sm font-normal leading-6 tracking-normal text-slate-600 dark:text-woot-50"
>
{{
useInstallationName(
@@ -36,7 +36,7 @@
:loading="resetPassword.showLoading"
/>
</div>
<p class="text-sm text-slate-600 dark:text-woot-50 mt-4 -mb-1">
<p class="mt-4 -mb-1 text-sm text-slate-600 dark:text-woot-50">
{{ $t('RESET_PASSWORD.GO_BACK_TO_LOGIN') }}
<router-link to="/auth/login" class="text-link">
{{ $t('COMMON.CLICK_HERE') }}.
@@ -101,7 +101,7 @@ export default {
if (error?.response?.data?.message) {
errorMessage = error.response.data.message;
}
this.showAlert(errorMessage);
this.showAlertMessage(errorMessage);
});
},
},
+8 -4
View File
@@ -6,12 +6,16 @@ import { validateRouteAccess } from '../helpers/RouteHelper';
export const router = new VueRouter({ mode: 'history', routes });
const sensitiveRouteNames = ['auth_password_edit'];
export const initalizeRouter = () => {
router.beforeEach((to, _, next) => {
AnalyticsHelper.page(to.name || '', {
path: to.path,
name: to.name,
});
if (!sensitiveRouteNames.includes(to.name)) {
AnalyticsHelper.page(to.name || '', {
path: to.path,
name: to.name,
});
}
return validateRouteAccess(to, next, window.chatwootConfig);
});
+4 -2
View File
@@ -214,7 +214,7 @@ export default {
login(credentials)
.then(() => {
this.showAlert(this.$t('LOGIN.API.SUCCESS_MESSAGE'));
this.showAlertMessage(this.$t('LOGIN.API.SUCCESS_MESSAGE'));
})
.catch(response => {
// Reset URL Params if the authentication is invalid
@@ -222,7 +222,9 @@ export default {
window.location = '/app/login';
}
this.loginApi.hasErrored = true;
this.showAlert(response?.message || this.$t('LOGIN.API.UNAUTH'));
this.showAlertMessage(
response?.message || this.$t('LOGIN.API.UNAUTH')
);
});
},
},
+8 -2
View File
@@ -71,6 +71,7 @@ export default {
messageCount: 'conversation/getMessageCount',
unreadMessageCount: 'conversation/getUnreadMessageCount',
isWidgetStyleFlat: 'appConfig/isWidgetStyleFlat',
showUnreadMessagesDialog: 'appConfig/getShowUnreadMessagesDialog',
}),
isIFrame() {
return IFrameHelper.isIFrame();
@@ -209,8 +210,13 @@ export default {
},
setUnreadView() {
const { unreadMessageCount } = this;
if (this.isIFrame && unreadMessageCount > 0 && !this.isWidgetOpen) {
if (!this.showUnreadMessagesDialog) {
this.handleUnreadNotificationDot();
} else if (
this.isIFrame &&
unreadMessageCount > 0 &&
!this.isWidgetOpen
) {
this.replaceRoute('unread-messages').then(() => {
this.setIframeHeight(true);
IFrameHelper.sendMessage({ event: 'setUnreadMode' });
@@ -30,7 +30,7 @@
/>
<div
v-if="hasAttachments"
class="chat-bubble has-attachment agent"
class="chat-bubble has-attachment space-y-2 agent"
:class="(wrapClass, $dm('bg-white', 'dark:bg-slate-700'))"
>
<div
@@ -44,6 +44,14 @@
:readable-time="readableTime"
@error="onImageLoadError"
/>
<video-bubble
v-if="attachment.file_type === 'video' && !hasVideoError"
:url="attachment.data_url"
:readable-time="readableTime"
@error="onVideoLoadError"
/>
<audio v-else-if="attachment.file_type === 'audio'" controls>
<source :src="attachment.data_url" />
</audio>
@@ -84,6 +92,7 @@ import AgentMessageBubble from 'widget/components/AgentMessageBubble.vue';
import MessageReplyButton from 'widget/components/MessageReplyButton.vue';
import timeMixin from 'dashboard/mixins/time';
import ImageBubble from 'widget/components/ImageBubble.vue';
import VideoBubble from 'widget/components/VideoBubble.vue';
import FileBubble from 'widget/components/FileBubble.vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import { MESSAGE_TYPE } from 'widget/helpers/constants';
@@ -100,6 +109,7 @@ export default {
components: {
AgentMessageBubble,
ImageBubble,
VideoBubble,
Thumbnail,
UserMessage,
FileBubble,
@@ -120,6 +130,7 @@ export default {
data() {
return {
hasImageError: false,
hasVideoError: false,
};
},
computed: {
@@ -215,15 +226,20 @@ export default {
watch: {
message() {
this.hasImageError = false;
this.hasVideoError = false;
},
},
mounted() {
this.hasImageError = false;
this.hasVideoError = false;
},
methods: {
onImageLoadError() {
this.hasImageError = true;
},
onVideoLoadError() {
this.hasVideoError = true;
},
toggleReply() {
emitter.emit(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.message);
},
@@ -39,6 +39,14 @@
:readable-time="readableTime"
@error="onImageLoadError"
/>
<video-bubble
v-if="attachment.file_type === 'video' && !hasVideoError"
:url="attachment.data_url"
:readable-time="readableTime"
@error="onVideoLoadError"
/>
<file-bubble
v-else
:url="attachment.data_url"
@@ -72,6 +80,7 @@
import UserMessageBubble from 'widget/components/UserMessageBubble.vue';
import MessageReplyButton from 'widget/components/MessageReplyButton.vue';
import ImageBubble from 'widget/components/ImageBubble.vue';
import VideoBubble from 'widget/components/VideoBubble.vue';
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import FileBubble from 'widget/components/FileBubble.vue';
import timeMixin from 'dashboard/mixins/time';
@@ -88,6 +97,7 @@ export default {
UserMessageBubble,
MessageReplyButton,
ImageBubble,
VideoBubble,
FileBubble,
FluentIcon,
ReplyToChip,
@@ -107,6 +117,7 @@ export default {
data() {
return {
hasImageError: false,
hasVideoError: false,
};
},
computed: {
@@ -143,10 +154,12 @@ export default {
watch: {
message() {
this.hasImageError = false;
this.hasVideoError = false;
},
},
mounted() {
this.hasImageError = false;
this.hasVideoError = false;
},
methods: {
async retrySendMessage() {
@@ -158,6 +171,9 @@ export default {
onImageLoadError() {
this.hasImageError = true;
},
onVideoLoadError() {
this.hasVideoError = true;
},
toggleReply() {
this.$emitter.emit(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.message);
},
@@ -0,0 +1,27 @@
<script setup>
defineProps({
url: { type: String, default: '' },
readableTime: { type: String, default: '' },
});
const emits = defineEmits(['error']);
const onVideoError = () => {
emits('error');
};
</script>
<template>
<div class="block relative max-w-full">
<video
class="w-full max-w-[250px] h-auto"
:src="url"
controls
@error="onVideoError"
/>
<span
class="text-xs absolute text-white dark:text-white right-3 bottom-1 whitespace-nowrap"
>
{{ readableTime }}
</span>
</div>
</template>
@@ -10,6 +10,7 @@ import {
const state = {
hideMessageBubble: false,
isCampaignViewClicked: false,
showUnreadMessagesDialog: true,
isWebWidgetTriggered: false,
isWidgetOpen: false,
position: 'right',
@@ -29,6 +30,7 @@ export const getters = {
getReferrerHost: $state => $state.referrerHost,
isWidgetStyleFlat: $state => $state.widgetStyle === 'flat',
darkMode: $state => $state.darkMode,
getShowUnreadMessagesDialog: $state => $state.showUnreadMessagesDialog,
};
export const actions = {
@@ -38,6 +40,7 @@ export const actions = {
showPopoutButton,
position,
hideMessageBubble,
showUnreadMessagesDialog,
widgetStyle = 'rounded',
darkMode = 'light',
}
@@ -46,6 +49,7 @@ export const actions = {
hideMessageBubble: !!hideMessageBubble,
position: position || 'right',
showPopoutButton: !!showPopoutButton,
showUnreadMessagesDialog: !!showUnreadMessagesDialog,
widgetStyle,
darkMode,
});
@@ -75,6 +79,7 @@ export const mutations = {
$state.widgetStyle = data.widgetStyle;
$state.darkMode = data.darkMode;
$state.locale = data.locale || $state.locale;
$state.showUnreadMessagesDialog = data.showUnreadMessagesDialog;
},
[TOGGLE_WIDGET_OPEN]($state, isWidgetOpen) {
$state.isWidgetOpen = isWidgetOpen;
@@ -13,4 +13,10 @@ describe('#getters', () => {
expect(getters.getReferrerHost(state)).toEqual('www.chatwoot.com');
});
});
describe('#getShowUnreadMessagesDialog', () => {
it('returns correct value', () => {
const state = { showUnreadMessagesDialog: true };
expect(getters.getShowUnreadMessagesDialog(state)).toEqual(true);
});
});
});
@@ -30,6 +30,8 @@ class Inboxes::FetchImapEmailsJob < MutexApplicationJob
def process_email_for_channel(channel, interval)
inbound_emails = if channel.microsoft?
Imap::MicrosoftFetchEmailService.new(channel: channel, interval: interval).perform
elsif channel.google?
Imap::GoogleFetchEmailService.new(channel: channel, interval: interval).perform
else
Imap::FetchEmailService.new(channel: channel, interval: interval).perform
end
+1 -1
View File
@@ -34,7 +34,7 @@ class ReportingEventListener < BaseListener
message.created_at),
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
user_id: conversation.assignee_id,
user_id: message.sender_id,
conversation_id: conversation.id,
event_start_time: last_non_human_activity(conversation),
event_end_time: message.created_at
+4 -16
View File
@@ -1,5 +1,6 @@
class Imap::ImapMailbox
include MailboxHelper
include IncomingEmailValidityHelper
attr_accessor :channel, :account, :inbox, :conversation, :processed_mail
def process(mail, channel)
@@ -9,11 +10,10 @@ class Imap::ImapMailbox
load_inbox
decorate_mail
# prevent loop from chatwoot notification emails
return if notification_email_from_chatwoot?
Rails.logger.info("Processing Email from: #{@processed_mail.original_sender} : inbox #{@inbox.id} : message_id #{@processed_mail.message_id}")
# Stop processing if email format doesn't match Chatwoot supported mail format
return unless email_from_valid_email?
# Skip processing email if it belongs to any of the edge cases
return unless incoming_email_from_valid_email?
ActiveRecord::Base.transaction do
find_or_create_contact
@@ -37,18 +37,6 @@ class Imap::ImapMailbox
@processed_mail = MailPresenter.new(@inbound_mail, @account)
end
def email_from_valid_email?
Rails.logger.info("Processing Email from: #{@processed_mail.original_sender} : inbox #{@inbox.id}")
# validate email with Devise.email_regexp
if Devise.email_regexp.match?(@processed_mail.original_sender)
true
else
Rails.logger.error("Email from: #{@processed_mail.original_sender} : inbox #{@inbox.id} is invalid")
false
end
end
def find_conversation_by_in_reply_to
return if in_reply_to.blank?
@@ -0,0 +1,35 @@
module IncomingEmailValidityHelper
private
def incoming_email_from_valid_email?
return false unless valid_external_email_for_active_account?
# we skip processing auto reply emails like delivery status notifications
# out of office replies, etc.
return false if auto_reply_email?
# return if email doesn't have a valid sender
# This can happen in cases like bounce emails for invalid contact email address
# TODO: Handle the bounce separately and mark the contact as invalid in case of reply bounces
# The returned value could be "\"\"" for some email clients
return false unless Devise.email_regexp.match?(@processed_mail.original_sender)
true
end
def valid_external_email_for_active_account?
return false unless @account.active?
return false if @processed_mail.notification_email_from_chatwoot?
true
end
def auto_reply_email?
if @processed_mail.auto_reply?
Rails.logger.info "is_auto_reply? : #{processed_mail.auto_reply?}"
true
else
false
end
end
end
-5
View File
@@ -112,11 +112,6 @@ module MailboxHelper
Rails.logger.info "[MailboxHelper] Contact created with ID: #{@contact.id} for inbox with ID: #{@inbox.id}"
end
def notification_email_from_chatwoot?
# notification emails are send via mailer sender email address. so it should match
@processed_mail.original_sender == Mail::Address.new(ENV.fetch('MAILER_SENDER_EMAIL', 'Chatwoot <accounts@chatwoot.com>')).address
end
def mail_content
if processed_mail.text_content.present?
processed_mail.text_content[:reply]
+3 -10
View File
@@ -1,4 +1,5 @@
class SupportMailbox < ApplicationMailbox
include IncomingEmailValidityHelper
attr_accessor :channel, :account, :inbox, :conversation, :processed_mail
before_processing :find_channel,
@@ -9,16 +10,8 @@ class SupportMailbox < ApplicationMailbox
def process
Rails.logger.info "Processing email #{mail.message_id} from #{original_sender_email} to #{mail.to} with subject #{mail.subject}"
# to turn off spam conversation creation
return unless @account.active?
# prevent loop from chatwoot notification emails
return if notification_email_from_chatwoot?
# return if email doesn't have a valid sender
# This can happen in cases like bounce emails for invalid contact email address
# TODO: Handle the bounce seperately and mark the contact as invalid
# we are checking for @ since the returned value could be "\"\"" for some email clients
return unless original_sender_email.include?('@')
# Skip processing email if it belongs to any of the edge cases
return unless incoming_email_from_valid_email?
ActiveRecord::Base.transaction do
find_or_create_contact
+25 -11
View File
@@ -14,6 +14,7 @@ module ConversationReplyMailerHelper
@options[:bcc] = cc_bcc_emails[1]
end
ms_smtp_settings
google_smtp_settings
set_delivery_method
Rails.logger.info("Email sent from #{email_from} to #{to_emails} with subject #{mail_subject}")
@@ -23,23 +24,36 @@ module ConversationReplyMailerHelper
private
def ms_smtp_settings
return unless @inbox.email? && @channel.imap_enabled && @inbox.channel.provider == 'microsoft'
def google_smtp_settings
return unless @inbox.email? && @channel.imap_enabled && @inbox.channel.google?
smtp_settings = {
address: 'smtp.office365.com',
smtp_settings = base_smtp_settings('smtp.gmail.com')
@options[:delivery_method] = :smtp
@options[:delivery_method_options] = smtp_settings
end
def ms_smtp_settings
return unless @inbox.email? && @channel.imap_enabled && @inbox.channel.microsoft?
smtp_settings = base_smtp_settings('smtp.office365.com')
@options[:delivery_method] = :smtp
@options[:delivery_method_options] = smtp_settings
end
def base_smtp_settings(domain)
{
address: domain,
port: 587,
user_name: @channel.imap_login,
password: @channel.provider_config['access_token'],
domain: 'smtp.office365.com',
domain: domain,
tls: false,
enable_starttls_auto: true,
openssl_verify_mode: 'none',
authentication: 'xoauth2'
}
@options[:delivery_method] = :smtp
@options[:delivery_method_options] = smtp_settings
end
def set_delivery_method
@@ -69,12 +83,12 @@ module ConversationReplyMailerHelper
@inbox.inbox_type == 'Email' && @channel.imap_enabled
end
def email_microsoft_auth_enabled
@inbox.inbox_type == 'Email' && @channel.provider == 'microsoft'
def email_oauth_enabled
@inbox.inbox_type == 'Email' && (@channel.microsoft? || @channel.google?)
end
def email_from
email_microsoft_auth_enabled || email_smtp_enabled ? channel_email_with_name : from_email_with_name
email_oauth_enabled || email_smtp_enabled ? channel_email_with_name : from_email_with_name
end
def email_reply_to
+8
View File
@@ -49,6 +49,14 @@ class AccountUser < ApplicationRecord
::Agents::DestroyJob.perform_later(account, user)
end
def permissions
if administrator?
GlobalConfigService.load('CHATWOOT_ADMIN_PERMISSIONS', 'account_manage').split(',')
else
GlobalConfigService.load('CHATWOOT_AGENT_PERMISSIONS', 'conversation_manage').split(',')
end
end
def push_event_data
{
id: id,
+4
View File
@@ -57,6 +57,10 @@ class Channel::Email < ApplicationRecord
provider == 'microsoft'
end
def google?
provider == 'google'
end
private
def ensure_forward_to_email
@@ -48,7 +48,6 @@ class JsonSchemaValidator < ActiveModel::Validator
# Add validation errors to the record with a formatted statement
validation_errors.each do |error|
# byebug
format_and_append_error(error, record)
end
end
+17
View File
@@ -146,8 +146,25 @@ class MailPresenter < SimpleDelegator
end
end
def auto_reply?
auto_submitted? || x_auto_reply?
end
def notification_email_from_chatwoot?
# notification emails are send via mailer sender email address. so it should match
original_sender == Mail::Address.new(ENV.fetch('MAILER_SENDER_EMAIL', 'Chatwoot <accounts@chatwoot.com>')).address
end
private
def auto_submitted?
@mail['Auto-Submitted'].present? && @mail['Auto-Submitted'].value != 'no'
end
def x_auto_reply?
@mail['X-Autoreply'].present? && @mail['X-Autoreply'].value == 'yes'
end
# forcing the encoding of the content to UTF-8 so as to be compatible with database and serializers
def encode_to_unicode(str)
return '' if str.blank?
+5 -1
View File
@@ -50,7 +50,11 @@ class ActionService
end
def assign_team(team_ids = [])
return unassign_team if team_ids[0]&.zero?
# FIXME: The explicit checks for zero or nil (string) is bad. Move
# this to a separate unassign action.
should_unassign = team_ids.blank? || %w[nil 0].include?(team_ids[0].to_s)
return @conversation.update!(team_id: nil) if should_unassign
# check if team belongs to account only if team_id is present
# if team_id is nil, then it means that the team is being unassigned
return unless !team_ids[0].nil? && team_belongs_to_account?(team_ids)
@@ -23,7 +23,7 @@ class AutoAssignment::InboxRoundRobinService
# end of queue management functions
# allowed member ids = [assignable online agents supplied by the assignement service]
# allowed member ids = [assignable online agents supplied by the assignment service]
# the values of allowed member ids should be in string format
def available_agent(allowed_agent_ids: [])
reset_queue unless validate_queue?
@@ -0,0 +1,62 @@
class BaseRefreshOauthTokenService
pattr_initialize [:channel!]
# Additional references: https://gitlab.com/gitlab-org/ruby/gems/gitlab-mail_room/-/blob/master/lib/mail_room/microsoft_graph/connection.rb
def access_token
return provider_config[:access_token] unless access_token_expired?
refreshed_tokens = refresh_tokens
refreshed_tokens[:access_token]
end
def access_token_expired?
expiry = provider_config[:expires_on]
return true if expiry.blank?
# Adding a 5 minute window to expiry check to avoid any race
# conditions during the fetch operation. This would assure that the
# tokens are updated when we fetch the emails.
Time.current.utc >= DateTime.parse(expiry) - 5.minutes
end
# Refresh the access tokens using the refresh token
# Refer: https://github.com/microsoftgraph/msgraph-sample-rubyrailsapp/tree/b4a6869fe4a438cde42b161196484a929f1bee46
def refresh_tokens
oauth_strategy = build_oauth_strategy
token_service = build_token_service(oauth_strategy)
new_tokens = token_service.refresh!.to_hash.slice(:access_token, :refresh_token, :expires_at)
update_channel_provider_config(new_tokens)
channel.reload.provider_config
end
def update_channel_provider_config(new_tokens)
channel.provider_config = {
access_token: new_tokens[:access_token],
refresh_token: new_tokens[:refresh_token],
expires_on: Time.at(new_tokens[:expires_at]).utc.to_s
}
channel.save!
end
private
def build_oauth_strategy
raise NotImplementedError
end
def provider_config
@provider_config ||= channel.provider_config.with_indifferent_access
end
# Builds the token service using OAuth2
def build_token_service(oauth_strategy)
OAuth2::AccessToken.new(
oauth_strategy.client,
provider_config[:access_token],
refresh_token: provider_config[:refresh_token]
)
end
end
@@ -0,0 +1,12 @@
# Refer: https://learn.microsoft.com/en-us/entra/identity-platform/configurable-token-lifetimes
class Google::RefreshOauthTokenService < BaseRefreshOauthTokenService
private
# Builds the OAuth strategy for Microsoft Graph
def build_oauth_strategy
app_id = GlobalConfigService.load('GOOGLE_OAUTH_CLIENT_ID', nil)
app_secret = GlobalConfigService.load('GOOGLE_OAUTH_CLIENT_SECRET', nil)
OmniAuth::Strategies::GoogleOauth2.new(nil, app_id, app_secret)
end
end
@@ -0,0 +1,17 @@
class Imap::GoogleFetchEmailService < Imap::BaseFetchEmailService
def fetch_emails
return if channel.provider_config['access_token'].blank?
fetch_mail_for_channel
end
private
def authentication_type
'XOAUTH2'
end
def imap_password
Google::RefreshOauthTokenService.new(channel: channel).access_token
end
end
@@ -1,64 +1,9 @@
# Refer: https://learn.microsoft.com/en-us/entra/identity-platform/configurable-token-lifetimes
class Microsoft::RefreshOauthTokenService
pattr_initialize [:channel!]
# Additional references: https://gitlab.com/gitlab-org/ruby/gems/gitlab-mail_room/-/blob/master/lib/mail_room/microsoft_graph/connection.rb
def access_token
return provider_config[:access_token] unless access_token_expired?
refreshed_tokens = refresh_tokens
refreshed_tokens[:access_token]
end
def access_token_expired?
expiry = provider_config[:expires_on]
return true if expiry.blank?
# Adding a 5 minute window to expiry check to avoid any race
# conditions during the fetch operation. This would assure that the
# tokens are updated when we fetch the emails.
Time.current.utc >= DateTime.parse(expiry) - 5.minutes
end
# Refresh the access tokens using the refresh token
# Refer: https://github.com/microsoftgraph/msgraph-sample-rubyrailsapp/tree/b4a6869fe4a438cde42b161196484a929f1bee46
def refresh_tokens
oauth_strategy = build_oauth_strategy
token_service = build_token_service(oauth_strategy)
new_tokens = token_service.refresh!.to_hash.slice(:access_token, :refresh_token, :expires_at)
update_channel_provider_config(new_tokens)
channel.reload.provider_config
end
def update_channel_provider_config(new_tokens)
channel.provider_config = {
access_token: new_tokens[:access_token],
refresh_token: new_tokens[:refresh_token],
expires_on: Time.at(new_tokens[:expires_at]).utc.to_s
}
channel.save!
end
class Microsoft::RefreshOauthTokenService < BaseRefreshOauthTokenService
private
def provider_config
@provider_config ||= channel.provider_config.with_indifferent_access
end
# Builds the OAuth strategy for Microsoft Graph
def build_oauth_strategy
::MicrosoftGraphAuth.new(nil, GlobalConfigService.load('AZURE_APP_ID', ''), GlobalConfigService.load('AZURE_APP_SECRET', ''))
end
# Builds the token service using OAuth2
def build_token_service(oauth_strategy)
OAuth2::AccessToken.new(
oauth_strategy.client,
provider_config[:access_token],
refresh_token: provider_config[:refresh_token]
)
end
end
+3 -8
View File
@@ -1,4 +1,4 @@
class FCMService
class Notification::FcmService
SCOPES = ['https://www.googleapis.com/auth/firebase.messaging'].freeze
def initialize(project_id, credentials)
@@ -24,7 +24,7 @@ class FCMService
def generate_token
authorizer = Google::Auth::ServiceAccountCredentials.make_creds(
json_key_io: File.open(credentials_path),
json_key_io: credentials_path,
scope: SCOPES
)
token = authorizer.fetch_access_token!
@@ -35,11 +35,6 @@ class FCMService
end
def credentials_path
file = Tempfile.new(['firebase', '.json'])
file.write(@credentials)
file.rewind
file.path
ensure
file.close
StringIO.new(@credentials)
end
end

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