Compare commits

...

124 Commits

Author SHA1 Message Date
Abhinav Raut
95ae55dabd add missing comma in get-contact-chat-conversations query 2025-10-04 14:29:32 +05:30
Abhinav Raut
0dedc0b68e remove missing query from queries struct 2025-10-04 14:16:54 +05:30
Abhinav Raut
0f207a0cd8 remove missing query from query map 2025-10-04 13:58:55 +05:30
Abhinav Raut
6840f73be4 fix component imports 2025-10-04 13:51:57 +05:30
Abhinav Raut
bf1cf025e0 Merge branch 'main' into feat/live-chat-channel 2025-10-04 13:38:42 +05:30
Abhinav Raut
4a1e7af2fa do not sending typing ws message to widget clients if it's a private message being typed by agent 2025-10-04 12:32:34 +05:30
Abhinav Raut
9c43b8858c hide live chat continuity emails in AgentMessageBubble 2025-10-04 07:49:59 +05:30
Abhinav Raut
a4b5340a61 move file system media url secret and expiry duration to fs.go 2025-10-04 07:49:46 +05:30
Abhinav Raut
f7e243f3fc update fetchMessageAttachments to use signed URLs for media attachments with a 4-hour expiration 2025-10-04 07:33:14 +05:30
Abhinav Raut
3d76cce66a Merge pull request #151 from csr4422/enhancement/relative-time-months
Add month support to getRelativeTime
2025-10-03 13:39:14 +05:30
csr4422
4b8f30184a style: adjust spacing 2025-10-03 13:09:00 +05:30
Abhinav Raut
16ca6b6df7 Send batched unread messages in conversation continuity email after contact has not seen a conversation for configured amount of time, instead of sending a single message immediately when contact is disconnected on websocket.
Make the batching interval and time threshold for unread messages configurable
2025-10-03 01:54:17 +05:30
csr4422
e4018ddab8 Add month support to getRelativeTime 2025-10-01 16:22:02 +05:30
Abhinav Raut
02e8a43587 Update README.md 2025-09-30 14:45:51 +05:30
Abhinav Raut
f3acc37405 update version to 0.8.0-beta in package.json
remove caution note about alpha status from README.md
2025-09-30 14:35:53 +05:30
Abhinav Raut
562babf222 Merge pull request #150 from csr4422/fix/convert-select-values
Fix: convert select values to number or null before submit
2025-09-29 23:01:36 +05:30
csr4422
93e94432f5 feat: add None option to SLA and Business Hours select boxes 2025-09-29 21:20:20 +05:30
csr4422
ec63604163 Fix: convert select values to number or null before submit 2025-09-29 17:18:36 +05:30
Abhinav Raut
f06da2a861 Merge pull request #149 from csr4422/add-password-toggle-btn
Add password toggle to login page
2025-09-26 17:01:35 +05:30
csr4422
98f16854c8 Add password toggle to login page 2025-09-26 16:50:43 +05:30
Abhinav Raut
1de54fe110 add network connection banner in live chat widget 2025-09-25 23:37:54 +05:30
Abhinav Raut
54e614422d refactor and fixes to convo continuity 2025-09-25 02:49:23 +05:30
Abhinav Raut
1deeaf6df3 remove unnecessary descriptions for fields and translations 2025-09-25 02:32:00 +05:30
Abhinav Raut
3a5990174b fixes to pre chat form 2025-09-25 02:20:00 +05:30
Abhinav Raut
c7291b1d1a fix layout for live chat inbox tabs for smaller devices
hide admin help text on smaller devices
2025-09-24 23:54:26 +05:30
Abhinav Raut
5de870c446 Config option to show or hide ‘Powered by Libredesk’ in the live chat widget.
Make the start conversation button a floating button and add a gradient fade overlay
2025-09-24 23:34:39 +05:30
Abhinav Raut
d7067bce7d feat: add email fallback / continutiy inbox feature for live chat inbox 2025-09-21 00:51:19 +05:30
Abhinav Raut
cc36ef5a3a bump simple s3 2025-09-18 00:44:27 +05:30
Abhinav Raut
969d6ea4f9 Merge pull request #129 from abhinavxd/dependabot/go_modules/github.com/go-viper/mapstructure/v2-2.4.0
chore(deps): bump github.com/go-viper/mapstructure/v2 from 2.0.0-alpha.1 to 2.4.0
2025-09-17 19:23:14 +05:30
Abhinav Raut
326ccdf9d4 Merge pull request #138 from abhinavxd/dependabot/npm_and_yarn/frontend/axios-1.12.0
chore(deps): bump axios from 1.8.2 to 1.12.0 in /frontend
2025-09-17 19:22:03 +05:30
Abhinav Raut
d6a8e76472 Merge pull request #140 from abhinavxd/dependabot/npm_and_yarn/frontend/vite-5.4.20
chore(deps-dev): bump vite from 5.4.19 to 5.4.20 in /frontend
2025-09-17 19:21:41 +05:30
Abhinav Raut
f95b374b74 Merge pull request #139 from abhinavxd/dependabot/npm_and_yarn/frontend/vue-i18n-9.14.5
chore(deps): bump vue-i18n from 9.14.3 to 9.14.5 in /frontend
2025-09-17 19:21:28 +05:30
dependabot[bot]
a1db6ccb31 chore(deps-dev): bump vite from 5.4.19 to 5.4.20 in /frontend
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.19 to 5.4.20.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.20/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.20/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 5.4.20
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-17 13:51:25 +00:00
dependabot[bot]
267a6027ee chore(deps): bump vue-i18n from 9.14.3 to 9.14.5 in /frontend
Bumps [vue-i18n](https://github.com/intlify/vue-i18n/tree/HEAD/packages/vue-i18n) from 9.14.3 to 9.14.5.
- [Release notes](https://github.com/intlify/vue-i18n/releases)
- [Changelog](https://github.com/intlify/vue-i18n/blob/master/CHANGELOG.md)
- [Commits](https://github.com/intlify/vue-i18n/commits/v9.14.5/packages/vue-i18n)

---
updated-dependencies:
- dependency-name: vue-i18n
  dependency-version: 9.14.5
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-17 13:50:50 +00:00
dependabot[bot]
3471263710 chore(deps): bump axios from 1.8.2 to 1.12.0 in /frontend
Bumps [axios](https://github.com/axios/axios) from 1.8.2 to 1.12.0.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.8.2...v1.12.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.12.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-17 13:50:40 +00:00
Abhinav Raut
7469e296d2 fix: allow message processing to continue on thumbnail generation errors 2025-09-17 12:51:56 +05:30
Abhinav Raut
44ffc77c4e log conversation deletion 2025-09-17 02:47:06 +05:30
Abhinav Raut
3ec061d8f1 fix: improve error handling for message attachment uploads delete newly created conversation if media upload fails so this message is tried again 2025-09-17 02:42:07 +05:30
Abhinav Raut
48b8d14f8f Update README.md 2025-09-16 23:16:26 +05:30
Abhinav Raut
6231a9e131 update logger to log time value instead of null time 2025-09-16 23:15:09 +05:30
Abhinav Raut
d63302843b Refactor: assigned user removal when changing assigned team 2025-09-16 21:31:53 +05:30
Abhinav Raut
a652f380b2 fix: set default content type to text if empty
Ref #137
2025-09-16 21:10:20 +05:30
Abhinav Raut
a4a9a9ccd3 trim feedback length to 1000 chars 2025-09-15 23:29:57 +05:30
Abhinav Raut
71865e389e fix: Change id type to int in ConversationParticipant
- hide Total field in UserCompact JSON
2025-09-15 01:01:51 +05:30
Abhinav Raut
ae470be4c8 remove mkdocs docs as docs are moved to docs.libredesk.io repository 2025-09-14 22:48:32 +05:30
Abhinav Raut
636742c34b Update docs link to point to new docs domain docs.libredesk.io 2025-09-14 22:47:05 +05:30
Abhinav Raut
de77c03f66 Merge pull request #135 from abhinavxd/fix/contact-form-calling-code
Fix: Contact form displays countries with the same calling code incorrectly
2025-09-14 22:27:06 +05:30
Abhinav Raut
b7092744fd feat: add loading fade effect to ContactDetailView and adjust FormItem width in ContactForm 2025-09-14 21:43:40 +05:30
Abhinav Raut
6f300bb073 Fix: Contact form displays countries with the same calling code incorrectly.
For example, when a user selects the USA, the form also shows Canada, as both share the +1 calling code.

Rename column from `phone_number_calling_code` to `phone_number_country_code`.

Feat: Show the calling code alongside the country flag in the contact form for the selected country. Previously, only the flag was displayed.
2025-09-14 19:36:30 +05:30
Abhinav Raut
a8ca12fb9a Update README.md 2025-09-13 22:27:21 +05:30
Abhinav Raut
e4bec993e6 Update README.md 2025-09-13 22:12:20 +05:30
Abhinav Raut
efc01be7d3 Update hero image link in README.md 2025-09-13 22:07:04 +05:30
Abhinav Raut
ec72c5af90 style: update dark mode colors for card and popover in main.scss; Update colors in ContactNotes.vue 2025-09-13 21:55:30 +05:30
Abhinav Raut
490417cf9d clarify file upload extension form input in general setting s 2025-09-13 21:46:35 +05:30
Abhinav Raut
4f54db3d1b Conversation sidebar: Show last message and conversation created timestamps in the previous converastions accordion, with tooltip for full timestamps.
- Update util getRelativeTime to return shorter relative time strings instead of full date strings.
2025-09-13 21:33:28 +05:30
Abhinav Raut
210b8bb53b feat: add support for sending messages as contact
- introduce new permission `messages:write_as_contact` that needs to be set to allow this.
2025-09-13 20:55:04 +05:30
Abhinav Raut
a0e1ccf117 clear assigned user ID and last seen timestamp when updating conversation assigned team 2025-09-11 22:09:06 +05:30
Abhinav Raut
faf2082561 fix view form validation happening while filters are being added, delay validation on form submit
Set missing .stop modified on click events
2025-09-11 00:16:58 +05:30
Abhinav Raut
50baa8491b add title attribute to view name in sidebar 2025-09-11 00:13:08 +05:30
Abhinav Raut
8e89e4e0d4 fix(multi-select): add @input.stop to prevent event bubbling and corrupting state when it's wrapped e.g. like it's done in the Filterbuilder component 2025-09-10 03:06:32 +05:30
Abhinav Raut
b15413b7ca Add support for filter conversations from views using tags assigned.
Add multi select support to FilterBuilder component.

Ref #124
2025-09-10 03:02:55 +05:30
Abhinav Raut
701e5b2580 show sidebar view dropdown Ellipsis only on hover of single view.
add toast messages for update, create, delete of view
add confirmation dialog for view deletion
2025-09-10 02:48:48 +05:30
Abhinav Raut
dbd4e97f7e use tag store to fetch tags in conversation side bar to remove duplicate api call 2025-09-09 23:43:25 +05:30
Abhinav Raut
007c332a7d remove font-medium from data table columns in all data tables
- Remove permissions requirement for GET on roles
2025-09-09 23:22:08 +05:30
Abhinav Raut
4fcad4fd81 fix translation 2025-09-02 03:10:08 +05:30
Abhinav Raut
bece58bdec fix[automation] respect case sensitive flag for contains and not contains operator
- test cases for automation evaluator
2025-09-02 02:31:08 +05:30
Abhinav Raut
6d2d8f78d4 Merge pull request #130 from abhinavxd/refactor-apis
Clean up APIs and fixes
2025-09-02 02:27:28 +05:30
Abhinav Raut
98492a1869 refactor: use team compact struct for user teams list
- Check for existing email before agent update and raise proper error
2025-09-02 02:00:35 +05:30
Abhinav Raut
18b50b11c8 reduce footer font size for webtemplates 2025-09-02 01:10:03 +05:30
Abhinav Raut
5a1628f710 fix fetch general settings after user logs in 2025-09-02 00:57:05 +05:30
Abhinav Raut
12ebe32ba3 return complete contact note by refetching it using GetNote 2025-09-02 00:32:29 +05:30
Abhinav Raut
fce2587a9d remove unncessary margin from oidc provider logo
add alt attribute
2025-09-01 03:47:10 +05:30
Abhinav Raut
7d92ac9cce fix cypress test 2025-09-01 03:43:13 +05:30
Abhinav Raut
3ce3c5e0ee store public config in pinia store 2025-09-01 03:20:09 +05:30
Abhinav Raut
35ad00ec51 Add loading spinner to ConversationPlaceholder
Add missing i18n translation
2025-09-01 02:41:47 +05:30
Abhinav Raut
9ec96be959 rename AppUpdate component with AdminBanner
- show banner when app restart is required.
- UI changes to admin banner
2025-08-31 20:08:38 +05:30
Abhinav Raut
6ca36d611f add missing i18n key 2025-08-31 18:57:08 +05:30
Abhinav Raut
5a87d24d72 update var name 2025-08-31 18:55:31 +05:30
Abhinav Raut
7d4e7e68c3 update user avatar upload function to accept user by value and improve error logging by logging the user id 2025-08-31 18:48:02 +05:30
Abhinav Raut
5b941fd993 Apply suggestion from @Copilot
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-31 18:37:05 +05:30
Abhinav Raut
63e348e512 remove subject from csat page 2025-08-30 22:27:48 +05:30
Abhinav Raut
10a845dc81 fix cypress test 2025-08-30 22:27:13 +05:30
Abhinav Raut
0228989202 fix cypress test 2025-08-30 22:18:00 +05:30
Abhinav Raut
3f7d151d33 - add getting started flow for new users
- Translate web template pass i18n dependency
- Fix colors in menu card
- Show update description if avaialble in AppUpdate component
- Remvoe i18n from settings as i18n and settings depend on each other to load initial lang.
- Clear inbox password as the update SQL query now returns the config.
- Fetch agents and inboxes from the store instead of directly fetching using axios instance.
2025-08-30 21:30:24 +05:30
Abhinav Raut
a516773b14 feat: add i18n support to web templates
- Add i18n object to template funcMap for direct access
  - Translate all hardcoded strings in CSAT and footer templates
  - Add reusable translation keys to globals.messages
2025-08-30 19:35:00 +05:30
Abhinav Raut
f6d3bd543f refactor: consolidate public config into single endpoint, move settings behind auth
- remove OIDC enabled endpoint
2025-08-30 18:46:37 +05:30
Abhinav Raut
074d147bb6 Update README.md 2025-08-30 03:58:26 +05:30
Abhinav Raut
c1c14f7f54 refactor: split converstion list item and conversation into different structs
- Add missing columns in message queries
2025-08-29 00:15:22 +05:30
Abhinav Raut
634fc66e9f Translate welcome to libredesk email subject
- Update all SQL queries to add missing columns

- Update the create conversation API to allow setting the initiator of a conversation. For example, we might want to use this API to create a conversation on behalf of a customer, with the first message coming from the customer instead of the agent. This param allows this.

- Minor refactors and clean up

- Tidy go.mod

- Rename structs to reflect purpose

- Create focus structs for scanning JSON payloads for clarity.
2025-08-28 00:34:56 +05:30
Abhinav Raut
ed448055ed fix: newly created conversation not being added to the conversation list, simplify chat conversation SQL queries.
- add indexes to make conversation unread message count faster
2025-08-26 03:03:34 +05:30
dependabot[bot]
78b8607d8f chore(deps): bump github.com/go-viper/mapstructure/v2
Bumps [github.com/go-viper/mapstructure/v2](https://github.com/go-viper/mapstructure) from 2.0.0-alpha.1 to 2.4.0.
- [Release notes](https://github.com/go-viper/mapstructure/releases)
- [Changelog](https://github.com/go-viper/mapstructure/blob/main/CHANGELOG.md)
- [Commits](https://github.com/go-viper/mapstructure/compare/v2.0.0-alpha.1...v2.4.0)

---
updated-dependencies:
- dependency-name: github.com/go-viper/mapstructure/v2
  dependency-version: 2.4.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-25 20:35:13 +00:00
Abhinav Raut
0dec822c1c fix panic due to missing i18n dependency 2025-08-26 01:12:30 +05:30
Abhinav Raut
c721d19b81 fix migration 2025-08-24 02:27:17 +05:30
Abhinav Raut
77111835cc fix component import 2025-08-24 02:17:28 +05:30
Abhinav Raut
45a77b1422 fix build 2025-08-24 02:01:21 +05:30
Abhinav Raut
9a77c8953c Merge branch 'main' into feat/live-chat-channel 2025-08-24 01:52:12 +05:30
Abhinav Raut
18d4a8fe3b feat: auto-remove pending outgoing widget messages after 10 seconds if they have a temporary ID 2025-08-23 19:24:14 +05:30
Abhinav Raut
a2234e908f make widget expand to full viewport height
update shadows for iframe and widget
2025-08-22 02:24:23 +05:30
Abhinav Raut
d7fe6153bb Center pre chat form title 2025-08-22 02:00:53 +05:30
Abhinav Raut
68c2708464 feat: remove VisitorInfoForm component and integrate customizable pre-chat form.
- Deleted the VisitorInfoForm.vue component and its associated schema.
- Introduced a new preChatFormSchema.js to handle dynamic form validation.
- Updated ChatView.vue to conditionally display the PreChatForm based on user session and conversation state.
- Enhanced chat store to manage current conversation updates.
- Implemented WebSocket event handling for conversation updates.
- Updated localization files to include new terms related to the pre-chat form.
- Modified conversation management logic to support broadcasting updates to widget clients.
- Updated SQL queries to accommodate custom attributes for visitors.
2025-08-22 00:42:12 +05:30
Abhinav Raut
958f5e38c0 Merge pull request #127 from abhinavxd/fix/empty-message-id
fix: handle malformed & empty Message-IDs with multiple @ symbols in IMAP processing
2025-08-20 04:51:51 +05:30
Abhinav Raut
550a3fa801 fix: update Message-ID determination logic to prefer IMAP-parsed IDs over raw headers 2025-08-20 04:26:23 +05:30
Abhinav Raut
6bbfbe8cf6 add test cases for imap msg id parsing 2025-08-20 04:18:37 +05:30
Abhinav Raut
f9ed326d72 fix: handle message empty message ids in imap 2025-08-20 03:29:16 +05:30
Abhinav Raut
e0dc0285a4 fix: agents availability status changing to online after doing an email password login even after being Away or in Reassinging Replies status.
This was not affecting OIDC login just email password login
2025-08-19 16:34:15 +05:30
Abhinav Raut
4f9fc029c0 show uploading state when file is being uploaded from widget 2025-08-19 03:22:12 +05:30
Abhinav Raut
6cfa93838a fix: remove unnecessary filter from default icon styling in widget 2025-08-19 03:01:28 +05:30
Abhinav Raut
f72f158cf0 - show thumbnail image in widget thread instead of the entire image
- update file imports to use shared-ui utils and remove redundant file.js
- Implement SignedURLStore interface for fs store
2025-08-19 03:01:21 +05:30
Abhinav Raut
1962abdc16 feat: implement rate limiting for public widget endpoints with Redis support 2025-08-19 01:58:13 +05:30
Abhinav Raut
081a5c615a fix: update main.js to import styles from shared-ui instead 2025-08-03 17:35:52 +05:30
Abhinav Raut
c35ab42b47 feat: configurable visitor information collection with a form before starting chat.
fix: Chat initialization failing due to the JWT authenticated user doesn't exist in the DB yet.

fix: Always upsert custom attribues instead of replacing.
2025-07-21 01:58:30 +05:30
Abhinav Raut
f05014f412 refactor: implement widget authentication middleware with standard HTTP headers
- Add widgetAuth middleware to handle JWT and inbox validation consistently
  - Move authentication logic from request body to standard HTTP headers:
    * JWT: Authorization: Bearer <token>
    * Inbox ID: X-Libredesk-Inbox-ID: <id>
  - Refactor all widget handlers to use middleware context instead of duplicate auth code
  - Frontend now sends auth headers via HTTP interceptor for all widget requests
2025-07-20 17:44:36 +05:30
Abhinav Raut
e2bba04669 Fix: Trusted domain validation for live chat widget, check the referrer header instead of origin.
- Removed the widgetOrigin middleware as it would have same origin as the iFrame URL, changed this to use `Referrer` header on initial iFrame load.
- Feat(agent-view): Added external_user_id display in the conversation sidebar.
2025-07-20 16:44:33 +05:30
Abhinav Raut
4beab72a11 feat: add external user ID support and secret field for inboxes.
Update user and inbox models, queries, and migrations
2025-07-20 16:42:03 +05:30
Abhinav Raut
26b3b30fca feat: add authenticated user support by passing JWT from parent to widget iframe.
feat: more methods to toggle wiget visibility
2025-07-20 16:40:44 +05:30
Abhinav Raut
11fd57adb0 update lucide-vue-next to version 0.525.0 2025-07-20 16:20:26 +05:30
Abhinav Raut
d4f644c531 feat translate widget app 2025-07-17 02:56:32 +05:30
Abhinav Raut
646bbc7efe wait for widget vue app to be ready before showing the widget icon
- show arrow down when when widget is open
2025-07-17 02:37:03 +05:30
Abhinav Raut
3c3709557e feat: Add loading indicators to chat components and improve spinner UI 2025-07-17 02:29:05 +05:30
Abhinav Raut
74732bfe91 feat: Add expand/collapse functionality to chat view 2025-07-17 01:49:22 +05:30
Abhinav Raut
8ee81c2d64 feat: Widget dark mode and chat reply expectation message in chat title.
feat: Add HTTP utility functions for trusted origin checks

feat: Implement typing status broadcasting for live chat clients and agents.

feat: Add support for signed URLs in media manager

fix: Update database migration to handle duplicate visitors with same email address.

feat: Add conversation subscription and typing message models for WebSocket communication

feat: Implement conversation subscription management in WebSocket hub this is used for broadcasting typing indicator.

feat: Revamp widget JavaScript to improve mobile responsiveness and show unread messages if any.
2025-07-17 01:06:54 +05:30
Abhinav Raut
282dc83439 fix set correct var name 2025-07-06 18:47:19 +05:30
Abhinav Raut
61a70f6b52 clean up live chat
move last message details in the `meta` JSONB column of conversations
2025-07-06 18:46:54 +05:30
Abhinav Raut
5b6a58fba0 wip: intercom like live chat with chat widget
- new vue app for serving live chat widget, created subdirectories inside frontend dir `main` and `widget`
- vite changes for both main app and widget app.
- new backend live chat channel
- apis for live chat widget
2025-06-29 04:59:55 +05:30
654 changed files with 15775 additions and 3440 deletions

View File

@@ -1,31 +0,0 @@
name: Deploy MkDocs
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: 3.x
- run: pip install mkdocs-material
- run: |
if [ -f requirements.txt ]; then
pip install -r requirements.txt;
fi
- run: cd docs && mkdocs build
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./docs/site

View File

@@ -15,7 +15,7 @@ GOPATH ?= $(HOME)/go
STUFFBIN ?= $(GOPATH)/bin/stuffbin
# The default target to run when `make` is executed.
.DEFAULT_GOAL := build
.DEFAULT_GOAL := build
# Install stuffbin if it doesn't exist.
$(STUFFBIN):
@@ -28,11 +28,24 @@ install-deps: $(STUFFBIN)
@echo "→ Installing frontend dependencies..."
@cd ${FRONTEND_DIR} && pnpm install
# Build the frontend for production.
# Build the frontend for production (both apps).
.PHONY: frontend-build
frontend-build: install-deps
@echo "→ Building frontend for production..."
@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm build
@echo "→ Building frontend for production - main app & widget..."
@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm build:main
@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm build:widget
# Build only the main frontend app.
.PHONY: frontend-build-main
frontend-build-main: install-deps
@echo "→ Building main frontend app for production..."
@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm build:main
# Build only the widget frontend app.
.PHONY: frontend-build-widget
frontend-build-widget: install-deps
@echo "→ Building widget frontend app for production..."
@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm build:widget
# Run the Go backend server in development mode.
.PHONY: run-backend
@@ -40,13 +53,29 @@ run-backend:
@echo "→ Running backend..."
CGO_ENABLED=0 go run -ldflags="-s -w -X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}' -X 'github.com/abhinavxd/libredesk/internal/version.Version=${VERSION}' -X 'main.frontendDir=frontend/dist'" cmd/*.go
# Run the JS frontend server in development mode.
# Run the JS frontend server in development mode (main app only).
.PHONY: run-frontend
run-frontend:
@echo "→ Installing frontend dependencies (if not already installed)..."
@cd ${FRONTEND_DIR} && pnpm install
@echo "→ Running frontend..."
@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm dev
@echo "→ Running main frontend app..."
@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm dev:main
# Run the main frontend app in development mode.
.PHONY: run-frontend-main
run-frontend-main:
@echo "→ Installing frontend dependencies (if not already installed)..."
@cd ${FRONTEND_DIR} && pnpm install
@echo "→ Running main frontend app..."
@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm dev:main
# Run the widget frontend app in development mode.
.PHONY: run-frontend-widget
run-frontend-widget:
@echo "→ Installing frontend dependencies (if not already installed)..."
@cd ${FRONTEND_DIR} && pnpm install
@echo "→ Running widget frontend app..."
@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm dev:widget
# Build the backend binary.
.PHONY: build-backend

View File

@@ -3,15 +3,13 @@
# Libredesk
Open source, self-hosted customer support desk. Single binary app.
Modern, open source, self-hosted customer support desk. Single binary app.
![image](docs/docs/images/hero.png)
![image](https://libredesk.io/hero.png)
Visit [libredesk.io](https://libredesk.io) for more info. Check out the [**Live demo**](https://demo.libredesk.io/).
> **CAUTION:** This project is currently in **alpha**. Features and APIs may change and are not yet fully tested.
## Features
- **Multi Shared Inbox**
@@ -67,7 +65,7 @@ docker exec -it libredesk_app ./libredesk --set-system-user-password
Go to `http://localhost:9000` and login with username `System` and the password you set using the `--set-system-user-password` command.
See [installation docs](https://libredesk.io/docs/installation/)
See [installation docs](https://docs.libredesk.io/getting-started/installation)
__________________
@@ -78,17 +76,12 @@ __________________
- Run `./libredesk --set-system-user-password` to set the password for the System user.
- Run `./libredesk` and visit `http://localhost:9000` and login with username `System` and the password you set using the --set-system-user-password command.
See [installation docs](https://libredesk.io/docs/installation)
See [installation docs](https://docs.libredesk.io/getting-started/installation)
__________________
## Developers
If you are interested in contributing, refer to the [developer setup](https://libredesk.io/docs/developer-setup/). The backend is written in Go and the frontend is Vue js 3 with Shadcn for UI components.
## Development Status
Libredesk is under active development.
Track roadmap and progress on the GitHub Project Board: [https://github.com/users/abhinavxd/projects/1](https://github.com/users/abhinavxd/projects/1)
If you are interested in contributing, refer to the [developer setup](https://docs.libredesk.io/contributing/developer-setup). The backend is written in Go and the frontend is Vue js 3 with Shadcn for UI components.
## Translators

1129
cmd/chat.go Normal file

File diff suppressed because it is too large Load Diff

63
cmd/config.go Normal file
View File

@@ -0,0 +1,63 @@
package main
import (
"encoding/json"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/zerodha/fastglue"
)
// handleGetConfig returns the public configuration needed for app initialization, this includes minimal app settings and enabled SSO providers (without secrets).
func handleGetConfig(r *fastglue.Request) error {
var app = r.Context.(*App)
// Get app settings
settingsJSON, err := app.setting.GetByPrefix("app")
if err != nil {
return sendErrorEnvelope(r, err)
}
// Unmarshal settings
var settings map[string]any
if err := json.Unmarshal(settingsJSON, &settings); err != nil {
app.lo.Error("error unmarshalling settings", "err", err)
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorFetching", "name", app.i18n.T("globals.terms.setting")), nil))
}
// Filter to only include public fields needed for initial app load
publicSettings := map[string]any{
"app.lang": settings["app.lang"],
"app.favicon_url": settings["app.favicon_url"],
"app.logo_url": settings["app.logo_url"],
"app.site_name": settings["app.site_name"],
}
// Get all OIDC providers
oidcProviders, err := app.oidc.GetAll()
if err != nil {
return sendErrorEnvelope(r, err)
}
// Filter for enabled providers and remove client_secret
enabledProviders := make([]map[string]any, 0)
for _, provider := range oidcProviders {
if provider.Enabled {
providerMap := map[string]any{
"id": provider.ID,
"name": provider.Name,
"provider": provider.Provider,
"provider_url": provider.ProviderURL,
"client_id": provider.ClientID,
"logo_url": provider.ProviderLogoURL,
"enabled": provider.Enabled,
"redirect_uri": provider.RedirectURI,
}
enabledProviders = append(enabledProviders, providerMap)
}
}
// Add SSO providers to the response
publicSettings["app.sso_providers"] = enabledProviders
return r.SendEnvelope(publicSettings)
}

View File

@@ -103,9 +103,9 @@ func handleUpdateContact(r *fastglue.Request) error {
if v, ok := form.Value["phone_number"]; ok && len(v) > 0 {
phoneNumber = string(v[0])
}
phoneNumberCallingCode := ""
if v, ok := form.Value["phone_number_calling_code"]; ok && len(v) > 0 {
phoneNumberCallingCode = string(v[0])
phoneNumberCountryCode := ""
if v, ok := form.Value["phone_number_country_code"]; ok && len(v) > 0 {
phoneNumberCountryCode = string(v[0])
}
avatarURL := ""
if v, ok := form.Value["avatar_url"]; ok && len(v) > 0 {
@@ -116,8 +116,8 @@ func handleUpdateContact(r *fastglue.Request) error {
if avatarURL == "null" {
avatarURL = ""
}
if phoneNumberCallingCode == "null" {
phoneNumberCallingCode = ""
if phoneNumberCountryCode == "null" {
phoneNumberCountryCode = ""
}
if phoneNumber == "null" {
phoneNumber = ""
@@ -146,7 +146,7 @@ func handleUpdateContact(r *fastglue.Request) error {
Email: null.StringFrom(email),
AvatarURL: null.NewString(avatarURL, avatarURL != ""),
PhoneNumber: null.NewString(phoneNumber, phoneNumber != ""),
PhoneNumberCallingCode: null.NewString(phoneNumberCallingCode, phoneNumberCallingCode != ""),
PhoneNumberCountryCode: null.NewString(phoneNumberCountryCode, phoneNumberCountryCode != ""),
}
if err := app.user.UpdateContact(id, contactToUpdate); err != nil {
@@ -164,11 +164,17 @@ func handleUpdateContact(r *fastglue.Request) error {
// Upload avatar?
files, ok := form.File["files"]
if ok && len(files) > 0 {
if err := uploadUserAvatar(r, &contact, files); err != nil {
if err := uploadUserAvatar(r, contact, files); err != nil {
return sendErrorEnvelope(r, err)
}
}
return r.SendEnvelope(true)
// Refetch contact and return it
contact, err = app.user.GetContact(id, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(contact)
}
// handleGetContactNotes returns all notes for a contact.
@@ -195,18 +201,21 @@ func handleCreateContactNote(r *fastglue.Request) error {
auser = r.RequestCtx.UserValue("user").(amodels.User)
req = createContactNoteReq{}
)
if err := r.Decode(&req, "json"); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil))
}
if len(req.Note) == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "note"), nil, envelope.InputError)
}
if err := app.user.CreateNote(contactID, auser.ID, req.Note); err != nil {
n, err := app.user.CreateNote(contactID, auser.ID, req.Note)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
n, err = app.user.GetNote(n.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(n)
}
// handleDeleteContactNote deletes a note for a contact.
@@ -240,6 +249,8 @@ func handleDeleteContactNote(r *fastglue.Request) error {
}
}
app.lo.Info("deleting contact note", "note_id", noteID, "contact_id", contactID, "actor_id", auser.ID)
if err := app.user.DeleteNote(noteID, contactID); err != nil {
return sendErrorEnvelope(r, err)
}
@@ -251,6 +262,7 @@ func handleBlockContact(r *fastglue.Request) error {
var (
app = r.Context.(*App)
contactID, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
auser = r.RequestCtx.UserValue("user").(amodels.User)
req = blockContactReq{}
)
@@ -262,8 +274,15 @@ func handleBlockContact(r *fastglue.Request) error {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil))
}
app.lo.Info("setting contact block status", "contact_id", contactID, "enabled", req.Enabled, "actor_id", auser.ID)
if err := app.user.ToggleEnabled(contactID, models.UserTypeContact, req.Enabled); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
contact, err := app.user.GetContact(contactID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(contact)
}

View File

@@ -49,6 +49,7 @@ type createConversationRequest struct {
Subject string `json:"subject"`
Content string `json:"content"`
Attachments []int `json:"attachments"`
Initiator string `json:"initiator"` // "contact" | "agent"
}
// handleGetAllConversations retrieves all conversations.
@@ -273,8 +274,8 @@ func handleGetConversation(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
prev, _ := app.conversation.GetContactConversations(conv.ContactID)
conv.PreviousConversations = filterCurrentConv(prev, conv.UUID)
prev, _ := app.conversation.GetContactPreviousConversations(conv.ContactID, 10)
conv.PreviousConversations = filterCurrentPreviousConv(prev, conv.UUID)
return r.SendEnvelope(conv)
}
@@ -474,11 +475,6 @@ func handleUpdateConversationStatus(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
// Make sure a user is assigned before resolving conversation.
if status == cmodels.StatusResolved && conversation.AssignedUserID.Int == 0 {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("conversation.resolveWithoutAssignee"), nil))
}
// Update conversation status.
if err := app.conversation.UpdateConversationStatus(uuid, 0 /**status_id**/, status, snoozedUntil, user); err != nil {
return sendErrorEnvelope(r, err)
@@ -583,7 +579,7 @@ func handleUpdateContactCustomAttributes(r *fastglue.Request) error {
if err != nil {
return sendErrorEnvelope(r, err)
}
if err := app.user.UpdateCustomAttributes(conversation.ContactID, attributes); err != nil {
if err := app.user.SaveCustomAttributes(conversation.ContactID, attributes, false); err != nil {
return sendErrorEnvelope(r, err)
}
// Broadcast update.
@@ -649,14 +645,14 @@ func handleRemoveTeamAssignee(r *fastglue.Request) error {
return r.SendEnvelope(true)
}
// filterCurrentConv removes the current conversation from the list of conversations.
func filterCurrentConv(convs []cmodels.Conversation, uuid string) []cmodels.Conversation {
// filterCurrentPreviousConv removes the current conversation from the list of previous conversations.
func filterCurrentPreviousConv(convs []cmodels.PreviousConversation, uuid string) []cmodels.PreviousConversation {
for i, c := range convs {
if c.UUID == uuid {
return append(convs[:i], convs[i+1:]...)
}
}
return []cmodels.Conversation{}
return []cmodels.PreviousConversation{}
}
// handleCreateConversation creates a new conversation and sends a message to it.
@@ -672,67 +668,42 @@ func handleCreateConversation(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
// Validate the request
if err := validateCreateConversationRequest(req, app); err != nil {
return sendErrorEnvelope(r, err)
}
to := []string{req.Email}
// Validate required fields
if req.InboxID <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`inbox_id`"), nil, envelope.InputError)
}
if req.Content == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`content`"), nil, envelope.InputError)
}
if req.Email == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`contact_email`"), nil, envelope.InputError)
}
if req.FirstName == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`first_name`"), nil, envelope.InputError)
}
if !stringutil.ValidEmail(req.Email) {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`contact_email`"), nil, envelope.InputError)
}
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
// Check if inbox exists and is enabled.
inbox, err := app.inbox.GetDBRecord(req.InboxID)
if err != nil {
return sendErrorEnvelope(r, err)
}
if !inbox.Enabled {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.disabled", "name", "inbox"), nil, envelope.InputError)
}
// Find or create contact.
contact := umodels.User{
Email: null.StringFrom(req.Email),
SourceChannelID: null.StringFrom(req.Email),
FirstName: req.FirstName,
LastName: req.LastName,
InboxID: req.InboxID,
Email: null.StringFrom(req.Email),
FirstName: req.FirstName,
LastName: req.LastName,
}
if err := app.user.CreateContact(&contact); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.contact}"), nil))
}
// Create conversation
// Create conversation first.
conversationID, conversationUUID, err := app.conversation.CreateConversation(
contact.ID,
contact.ContactChannelID,
req.InboxID,
"", /** last_message **/
time.Now(), /** last_message_at **/
req.Subject,
true, /** append reference number to subject **/
true, /** append reference number to subject? **/
)
if err != nil {
app.lo.Error("error creating conversation", "error", err)
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.conversation}"), nil))
}
// Prepare attachments.
// Get media for the attachment ids.
var media = make([]medModels.Media, 0, len(req.Attachments))
for _, id := range req.Attachments {
m, err := app.media.Get(id, "")
@@ -743,13 +714,29 @@ func handleCreateConversation(r *fastglue.Request) error {
media = append(media, m)
}
// Send reply to the created conversation.
if _, err := app.conversation.SendReply(media, req.InboxID, auser.ID /**sender_id**/, conversationUUID, req.Content, to, nil /**cc**/, nil /**bcc**/, map[string]any{} /**meta**/); err != nil {
// Delete the conversation if reply fails.
if err := app.conversation.DeleteConversation(conversationUUID); err != nil {
app.lo.Error("error deleting conversation", "error", err)
// Send initial message based on the initiator of conversation.
switch req.Initiator {
case umodels.UserTypeAgent:
// Queue reply.
if _, err := app.conversation.QueueReply(media, req.InboxID, auser.ID /**sender_id**/, contact.ID, conversationUUID, req.Content, to, nil /**cc**/, nil /**bcc**/, map[string]any{} /**meta**/); err != nil {
// Delete the conversation if msg queue fails.
if err := app.conversation.DeleteConversation(conversationUUID); err != nil {
app.lo.Error("error deleting conversation", "error", err)
}
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorSending", "name", "{globals.terms.message}"), nil))
}
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorSending", "name", "{globals.terms.message}"), nil))
case umodels.UserTypeContact:
// Create contact message.
if _, err := app.conversation.CreateContactMessage(media, contact.ID, conversationUUID, req.Content, cmodels.ContentTypeHTML); err != nil {
// Delete the conversation if message creation fails.
if err := app.conversation.DeleteConversation(conversationUUID); err != nil {
app.lo.Error("error deleting conversation", "error", err)
}
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorSending", "name", "{globals.terms.message}"), nil))
}
default:
// Guard anyway.
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`initiator`"), nil, envelope.InputError)
}
// Assign the conversation to the agent or team.
@@ -768,3 +755,36 @@ func handleCreateConversation(r *fastglue.Request) error {
return r.SendEnvelope(conversation)
}
// validateCreateConversationRequest validates the create conversation request fields.
func validateCreateConversationRequest(req createConversationRequest, app *App) error {
if req.InboxID <= 0 {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`inbox_id`"), nil)
}
if req.Content == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`content`"), nil)
}
if req.Email == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`contact_email`"), nil)
}
if req.FirstName == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`first_name`"), nil)
}
if !stringutil.ValidEmail(req.Email) {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`contact_email`"), nil)
}
if req.Initiator != umodels.UserTypeContact && req.Initiator != umodels.UserTypeAgent {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`initiator`"), nil)
}
// Check if inbox exists and is enabled.
inbox, err := app.inbox.GetDBRecord(req.InboxID)
if err != nil {
return err
}
if !inbox.Enabled {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.disabled", "name", "inbox"), nil)
}
return nil
}

View File

@@ -3,9 +3,19 @@ package main
import (
"strconv"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
type csatResponse struct {
Rating int `json:"rating"`
Feedback string `json:"feedback"`
}
const (
maxCsatFeedbackLength = 1000
)
// handleShowCSAT renders the CSAT page for a given csat.
func handleShowCSAT(r *fastglue.Request) error {
var (
@@ -17,7 +27,7 @@ func handleShowCSAT(r *fastglue.Request) error {
if err != nil {
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
"Data": map[string]interface{}{
"ErrorMessage": "Page not found",
"ErrorMessage": app.i18n.T("globals.messages.pageNotFound"),
},
})
}
@@ -25,8 +35,8 @@ func handleShowCSAT(r *fastglue.Request) error {
if csat.ResponseTimestamp.Valid {
return app.tmpl.RenderWebPage(r.RequestCtx, "info", map[string]interface{}{
"Data": map[string]interface{}{
"Title": "Thank you!",
"Message": "We appreciate you taking the time to submit your feedback.",
"Title": app.i18n.T("globals.messages.thankYou"),
"Message": app.i18n.T("csat.thankYouMessage"),
},
})
}
@@ -35,14 +45,14 @@ func handleShowCSAT(r *fastglue.Request) error {
if err != nil {
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
"Data": map[string]interface{}{
"ErrorMessage": "Page not found",
"ErrorMessage": app.i18n.T("globals.messages.pageNotFound"),
},
})
}
return app.tmpl.RenderWebPage(r.RequestCtx, "csat", map[string]interface{}{
"Data": map[string]interface{}{
"Title": "Rate your interaction with us",
"Title": app.i18n.T("csat.pageTitle"),
"CSAT": map[string]interface{}{
"UUID": csat.UUID,
},
@@ -67,15 +77,15 @@ func handleUpdateCSATResponse(r *fastglue.Request) error {
if err != nil {
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
"Data": map[string]interface{}{
"ErrorMessage": "Invalid `rating`",
"ErrorMessage": app.i18n.T("globals.messages.somethingWentWrong"),
},
})
}
if ratingI < 1 || ratingI > 5 {
if ratingI < 0 || ratingI > 5 {
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
"Data": map[string]interface{}{
"ErrorMessage": "Invalid `rating`",
"ErrorMessage": app.i18n.T("globals.messages.somethingWentWrong"),
},
})
}
@@ -83,11 +93,16 @@ func handleUpdateCSATResponse(r *fastglue.Request) error {
if uuid == "" {
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
"Data": map[string]interface{}{
"ErrorMessage": "Invalid `uuid`",
"ErrorMessage": app.i18n.T("globals.messages.somethingWentWrong"),
},
})
}
// Trim feedback if it exceeds max length
if len(feedback) > maxCsatFeedbackLength {
feedback = feedback[:maxCsatFeedbackLength]
}
if err := app.csat.UpdateResponse(uuid, ratingI, feedback); err != nil {
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
"Data": map[string]interface{}{
@@ -98,8 +113,41 @@ func handleUpdateCSATResponse(r *fastglue.Request) error {
return app.tmpl.RenderWebPage(r.RequestCtx, "info", map[string]interface{}{
"Data": map[string]interface{}{
"Title": "Thank you!",
"Message": "We appreciate you taking the time to submit your feedback.",
"Title": app.i18n.T("globals.messages.thankYou"),
"Message": app.i18n.T("csat.thankYouMessage"),
},
})
}
// handleSubmitCSATResponse handles CSAT response submission from the widget API.
func handleSubmitCSATResponse(r *fastglue.Request) error {
var (
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
req = csatResponse{}
)
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid JSON", nil, envelope.InputError)
}
if req.Rating < 0 || req.Rating > 5 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Rating must be between 0 and 5 (0 means no rating)", nil, envelope.InputError)
}
// At least one of rating or feedback must be provided
if req.Rating == 0 && req.Feedback == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Either rating or feedback must be provided", nil, envelope.InputError)
}
if uuid == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid UUID", nil, envelope.InputError)
}
// Update CSAT response
if err := app.csat.UpdateResponse(uuid, req.Rating, req.Feedback); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}

View File

@@ -1,12 +1,16 @@
package main
import (
"encoding/json"
"mime"
"net/http"
"path"
"path/filepath"
"strings"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/httputil"
"github.com/abhinavxd/libredesk/internal/inbox/channel/livechat"
"github.com/abhinavxd/libredesk/internal/ws"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
@@ -23,18 +27,20 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
// i18n.
g.GET("/api/v1/lang/{lang}", handleGetI18nLang)
// Public config for app initialization.
g.GET("/api/v1/config", handleGetConfig)
// Media.
g.GET("/uploads/{uuid}", auth(handleServeMedia))
g.POST("/api/v1/media", auth(handleMediaUpload))
// Settings.
g.GET("/api/v1/settings/general", handleGetGeneralSettings)
g.GET("/api/v1/settings/general", auth(handleGetGeneralSettings))
g.PUT("/api/v1/settings/general", perm(handleUpdateGeneralSettings, "general_settings:manage"))
g.GET("/api/v1/settings/notifications/email", perm(handleGetEmailNotificationSettings, "notification_settings:manage"))
g.PUT("/api/v1/settings/notifications/email", perm(handleUpdateEmailNotificationSettings, "notification_settings:manage"))
// OpenID connect single sign-on.
g.GET("/api/v1/oidc/enabled", handleGetAllEnabledOIDC)
g.GET("/api/v1/oidc", perm(handleGetAllOIDC, "oidc:manage"))
g.POST("/api/v1/oidc", perm(handleCreateOIDC, "oidc:manage"))
g.GET("/api/v1/oidc/{id}", perm(handleGetOIDC, "oidc:manage"))
@@ -153,7 +159,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.DELETE("/api/v1/inboxes/{id}", perm(handleDeleteInbox, "inboxes:manage"))
// Roles.
g.GET("/api/v1/roles", perm(handleGetRoles, "roles:manage"))
g.GET("/api/v1/roles", auth(handleGetRoles))
g.GET("/api/v1/roles/{id}", perm(handleGetRole, "roles:manage"))
g.POST("/api/v1/roles", perm(handleCreateRole, "roles:manage"))
g.PUT("/api/v1/roles/{id}", perm(handleUpdateRole, "roles:manage"))
@@ -209,13 +215,30 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
// Actvity logs.
g.GET("/api/v1/activity-logs", perm(handleGetActivityLogs, "activity_logs:manage"))
// CSAT.
g.POST("/api/v1/csat/{uuid}/response", handleSubmitCSATResponse)
// WebSocket.
g.GET("/ws", auth(func(r *fastglue.Request) error {
return handleWS(r, hub)
}))
// Live chat widget websocket.
g.GET("/widget/ws", handleWidgetWS)
// Widget APIs.
g.GET("/api/v1/widget/chat/settings/launcher", handleGetChatLauncherSettings)
g.GET("/api/v1/widget/chat/settings", handleGetChatSettings)
g.POST("/api/v1/widget/chat/conversations/init", rateLimitWidget(widgetAuth(handleChatInit)))
g.GET("/api/v1/widget/chat/conversations", rateLimitWidget(widgetAuth(handleGetConversations)))
g.POST("/api/v1/widget/chat/conversations/{uuid}/update-last-seen", rateLimitWidget(widgetAuth(handleChatUpdateLastSeen)))
g.GET("/api/v1/widget/chat/conversations/{uuid}", rateLimitWidget(widgetAuth(handleChatGetConversation)))
g.POST("/api/v1/widget/chat/conversations/{uuid}/message", rateLimitWidget(widgetAuth(handleChatSendMessage)))
g.POST("/api/v1/widget/media/upload", rateLimitWidget(widgetAuth(handleWidgetMediaUpload)))
// Frontend pages.
g.GET("/", notAuthPage(serveIndexPage))
g.GET("/widget", serveWidgetIndexPage)
g.GET("/inboxes/{all:*}", authPage(serveIndexPage))
g.GET("/teams/{all:*}", authPage(serveIndexPage))
g.GET("/views/{all:*}", authPage(serveIndexPage))
@@ -225,8 +248,12 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.GET("/account/{all:*}", authPage(serveIndexPage))
g.GET("/reset-password", notAuthPage(serveIndexPage))
g.GET("/set-password", notAuthPage(serveIndexPage))
// FIXME: Don't need three separate routes for the same thing.
// Assets and static files.
// FIXME: Reduce the number of routes.
g.GET("/widget.js", serveWidgetJS)
g.GET("/assets/{all:*}", serveFrontendStaticFiles)
g.GET("/widget/assets/{all:*}", serveWidgetStaticFiles)
g.GET("/images/{all:*}", serveFrontendStaticFiles)
g.GET("/static/public/{all:*}", serveStaticFiles)
@@ -263,6 +290,77 @@ func serveIndexPage(r *fastglue.Request) error {
return nil
}
// validateWidgetReferer validates the Referer header against trusted domains configured in the live chat inbox settings.
func validateWidgetReferer(app *App, r *fastglue.Request, inboxID int) error {
// Get the Referer header from the request
referer := string(r.RequestCtx.Request.Header.Peek("Referer"))
// If no referer header is present, allow direct access.
if referer == "" {
return nil
}
// Get inbox configuration
inbox, err := app.inbox.GetDBRecord(inboxID)
if err != nil {
app.lo.Error("error fetching inbox for referer check", "inbox_id", inboxID, "error", err)
return r.SendErrorEnvelope(http.StatusNotFound, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.inbox}"), nil, envelope.NotFoundError)
}
if !inbox.Enabled {
return r.SendErrorEnvelope(http.StatusBadRequest, app.i18n.Ts("globals.messages.disabled", "name", "{globals.terms.inbox}"), nil, envelope.InputError)
}
// Parse the live chat config
var config livechat.Config
if err := json.Unmarshal(inbox.Config, &config); err != nil {
app.lo.Error("error parsing live chat config for referer check", "error", err)
return r.SendErrorEnvelope(http.StatusInternalServerError, app.i18n.Ts("globals.messages.invalid", "name", "{globals.terms.inbox}"), nil, envelope.GeneralError)
}
// If trusted domains list is empty, allow all referers
if len(config.TrustedDomains) == 0 {
return nil
}
// Check if the referer matches any of the trusted domains
if !httputil.IsOriginTrusted(referer, config.TrustedDomains) {
app.lo.Warn("widget request from untrusted referer blocked",
"referer", referer,
"inbox_id", inboxID,
"trusted_domains", config.TrustedDomains)
return r.SendErrorEnvelope(http.StatusForbidden, "Widget not allowed from this origin: "+referer, nil, envelope.PermissionError)
}
app.lo.Debug("widget request from trusted referer allowed", "referer", referer, "inbox_id", inboxID)
return nil
}
// serveWidgetIndexPage serves the widget index page of the application.
func serveWidgetIndexPage(r *fastglue.Request) error {
app := r.Context.(*App)
// Extract inbox ID and validate trusted domains if present
inboxID := r.RequestCtx.QueryArgs().GetUintOrZero("inbox_id")
if err := validateWidgetReferer(app, r, inboxID); err != nil {
return err
}
// Prevent caching of the index page.
r.RequestCtx.Response.Header.Add("Cache-Control", "no-store, no-cache, must-revalidate, post-check=0, pre-check=0")
r.RequestCtx.Response.Header.Add("Pragma", "no-cache")
r.RequestCtx.Response.Header.Add("Expires", "-1")
// Serve the index.html file from the embedded filesystem.
file, err := app.fs.Get(path.Join(widgetDir, "index.html"))
if err != nil {
return r.SendErrorEnvelope(http.StatusNotFound, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.file}"), nil, envelope.NotFoundError)
}
r.RequestCtx.Response.Header.Set("Content-Type", "text/html")
r.RequestCtx.SetBody(file.ReadBytes())
return nil
}
// serveStaticFiles serves static assets from the embedded filesystem.
func serveStaticFiles(r *fastglue.Request) error {
app := r.Context.(*App)
@@ -311,6 +409,47 @@ func serveFrontendStaticFiles(r *fastglue.Request) error {
return nil
}
// serveWidgetStaticFiles serves widget static assets from the embedded filesystem.
func serveWidgetStaticFiles(r *fastglue.Request) error {
app := r.Context.(*App)
filePath := string(r.RequestCtx.Path())
finalPath := filepath.Join(widgetDir, strings.TrimPrefix(filePath, "/widget"))
file, err := app.fs.Get(finalPath)
if err != nil {
return r.SendErrorEnvelope(http.StatusNotFound, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.file}"), nil, envelope.NotFoundError)
}
// Set the appropriate Content-Type based on the file extension.
ext := filepath.Ext(filePath)
contentType := mime.TypeByExtension(ext)
if contentType == "" {
contentType = http.DetectContentType(file.ReadBytes())
}
r.RequestCtx.Response.Header.Set("Content-Type", contentType)
r.RequestCtx.SetBody(file.ReadBytes())
return nil
}
// serveWidgetJS serves the widget JavaScript file.
func serveWidgetJS(r *fastglue.Request) error {
app := r.Context.(*App)
// Set appropriate headers for JavaScript
r.RequestCtx.Response.Header.Set("Content-Type", "application/javascript")
r.RequestCtx.Response.Header.Set("Cache-Control", "public, max-age=3600") // Cache for 1 hour
// Serve the widget.js file from the embedded filesystem.
file, err := app.fs.Get("static/widget.js")
if err != nil {
return r.SendErrorEnvelope(http.StatusNotFound, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.file}"), nil, envelope.NotFoundError)
}
r.RequestCtx.SetBody(file.ReadBytes())
return nil
}
// sendErrorEnvelope sends a standardized error response to the client.
func sendErrorEnvelope(r *fastglue.Request, err error) error {
e, ok := err.(envelope.Error)

View File

@@ -1,10 +1,12 @@
package main
import (
"encoding/json"
"net/mail"
"strconv"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/inbox/channel/livechat"
imodels "github.com/abhinavxd/libredesk/internal/inbox/models"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
@@ -17,6 +19,12 @@ func handleGetInboxes(r *fastglue.Request) error {
if err != nil {
return sendErrorEnvelope(r, err)
}
for i := range inboxes {
if err := inboxes[i].ClearPasswords(); err != nil {
app.lo.Error("error clearing inbox passwords from response", "error", err)
return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.inbox}"), nil)
}
}
return r.SendEnvelope(inboxes)
}
@@ -154,9 +162,11 @@ func handleDeleteInbox(r *fastglue.Request) error {
// validateInbox validates the inbox
func validateInbox(app *App, inbox imodels.Inbox) error {
// Validate from address.
if _, err := mail.ParseAddress(inbox.From); err != nil {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalidFromAddress"), nil)
// Validate from address only for email channels.
if inbox.Channel == "email" {
if _, err := mail.ParseAddress(inbox.From); err != nil {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalidFromAddress"), nil)
}
}
if len(inbox.Config) == 0 {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "config"), nil)
@@ -167,5 +177,33 @@ func validateInbox(app *App, inbox imodels.Inbox) error {
if inbox.Channel == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "channel"), nil)
}
// Validate livechat-specific configuration
if inbox.Channel == livechat.ChannelLiveChat {
var config livechat.Config
if err := json.Unmarshal(inbox.Config, &config); err == nil {
// ShowOfficeHoursAfterAssignment cannot be enabled if ShowOfficeHoursInChat is disabled
if config.ShowOfficeHoursAfterAssignment && !config.ShowOfficeHoursInChat {
return envelope.NewError(envelope.InputError, "`show_office_hours_after_assignment` cannot be enabled when `show_office_hours_in_chat` is disabled", nil)
}
}
// Validate linked email inbox if specified
if inbox.LinkedEmailInboxID.Valid {
linkedInbox, err := app.inbox.GetDBRecord(int(inbox.LinkedEmailInboxID.Int))
if err != nil {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "linked_email_inbox_id"), nil)
}
// Ensure linked inbox is an email channel
if linkedInbox.Channel != "email" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "linked_email_inbox_id"), nil)
}
// Ensure linked inbox is enabled
if !linkedInbox.Enabled {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "linked_email_inbox_id"), nil)
}
}
}
return nil
}

View File

@@ -27,6 +27,7 @@ import (
customAttribute "github.com/abhinavxd/libredesk/internal/custom_attribute"
"github.com/abhinavxd/libredesk/internal/inbox"
"github.com/abhinavxd/libredesk/internal/inbox/channel/email"
"github.com/abhinavxd/libredesk/internal/inbox/channel/livechat"
imodels "github.com/abhinavxd/libredesk/internal/inbox/models"
"github.com/abhinavxd/libredesk/internal/macro"
"github.com/abhinavxd/libredesk/internal/media"
@@ -35,6 +36,7 @@ import (
notifier "github.com/abhinavxd/libredesk/internal/notification"
emailnotifier "github.com/abhinavxd/libredesk/internal/notification/providers/email"
"github.com/abhinavxd/libredesk/internal/oidc"
"github.com/abhinavxd/libredesk/internal/ratelimit"
"github.com/abhinavxd/libredesk/internal/report"
"github.com/abhinavxd/libredesk/internal/role"
"github.com/abhinavxd/libredesk/internal/search"
@@ -132,7 +134,8 @@ func initConstants() *constants {
// initFS initializes the stuffbin FileSystem.
func initFS() stuffbin.FileSystem {
var files = []string{
"frontend/dist",
"frontend/dist/main",
"frontend/dist/widget",
"i18n",
"static",
}
@@ -223,11 +226,30 @@ func initConversations(
template *tmpl.Manager,
webhook *webhook.Manager,
) *conversation.Manager {
continuityConfig := &conversation.ContinuityConfig{}
if ko.Exists("conversation.continuity.batch_check_interval") {
continuityConfig.BatchCheckInterval = ko.MustDuration("conversation.continuity.batch_check_interval")
}
if ko.Exists("conversation.continuity.offline_threshold") {
continuityConfig.OfflineThreshold = ko.MustDuration("conversation.continuity.offline_threshold")
}
if ko.Exists("conversation.continuity.min_email_interval") {
continuityConfig.MinEmailInterval = ko.MustDuration("conversation.continuity.min_email_interval")
}
if ko.Exists("conversation.continuity.max_messages_per_email") {
continuityConfig.MaxMessagesPerEmail = ko.MustInt("conversation.continuity.max_messages_per_email")
}
c, err := conversation.New(hub, i18n, notif, sla, status, priority, inboxStore, userStore, teamStore, mediaStore, settings, csat, automationEngine, template, webhook, conversation.Opts{
DB: db,
Lo: initLogger("conversation_manager"),
OutgoingMessageQueueSize: ko.MustInt("message.outgoing_queue_size"),
IncomingMessageQueueSize: ko.MustInt("message.incoming_queue_size"),
ContinuityConfig: continuityConfig,
})
if err != nil {
log.Fatalf("error initializing conversation manager: %v", err)
@@ -250,11 +272,12 @@ func initTag(db *sqlx.DB, i18n *i18n.I18n) *tag.Manager {
}
// initViews inits view manager.
func initView(db *sqlx.DB) *view.Manager {
func initView(db *sqlx.DB, i18n *i18n.I18n) *view.Manager {
var lo = initLogger("view_manager")
m, err := view.New(view.Opts{
DB: db,
Lo: lo,
DB: db,
Lo: lo,
I18n: i18n,
})
if err != nil {
log.Fatalf("error initializing view manager: %v", err)
@@ -327,7 +350,7 @@ func initWS(user *user.Manager) *ws.Hub {
func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts *constants, i18n *i18n.I18n) *tmpl.Manager {
var (
lo = initLogger("template")
funcMap = getTmplFuncs(consts)
funcMap = getTmplFuncs(consts, i18n)
)
tpls, err := stuffbin.ParseTemplatesGlob(funcMap, fs, "/static/email-templates/*.html")
if err != nil {
@@ -345,7 +368,7 @@ func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts *constants, i18n *
}
// getTmplFuncs returns the template functions.
func getTmplFuncs(consts *constants) template.FuncMap {
func getTmplFuncs(consts *constants, i18n *i18n.I18n) template.FuncMap {
return template.FuncMap{
"RootURL": func() string {
return consts.AppBaseURL
@@ -365,6 +388,9 @@ func getTmplFuncs(consts *constants) template.FuncMap {
"SiteName": func() string {
return consts.SiteName
},
"L": func() interface{} {
return i18n
},
}
}
@@ -381,7 +407,10 @@ func reloadSettings(app *App) error {
app.lo.Error("error unmarshalling settings from DB", "error", err)
return err
}
if err := ko.Load(confmap.Provider(out, "."), nil); err != nil {
app.Lock()
err = ko.Load(confmap.Provider(out, "."), nil)
app.Unlock()
if err != nil {
app.lo.Error("error loading settings into koanf", "error", err)
return err
}
@@ -393,7 +422,7 @@ func reloadSettings(app *App) error {
// reloadTemplates reloads the templates from the filesystem.
func reloadTemplates(app *App) error {
app.lo.Info("reloading templates")
funcMap := getTmplFuncs(app.consts.Load().(*constants))
funcMap := getTmplFuncs(app.consts.Load().(*constants), app.i18n)
tpls, err := stuffbin.ParseTemplatesGlob(funcMap, app.fs, "/static/email-templates/*.html")
if err != nil {
app.lo.Error("error parsing email templates", "error", err)
@@ -451,6 +480,8 @@ func initMedia(db *sqlx.DB, i18n *i18n.I18n) *media.Manager {
UploadURI: "/uploads",
UploadPath: filepath.Clean(ko.String("upload.fs.upload_path")),
RootURL: appRootURL,
Expiry: ko.Duration("upload.fs.expiry"),
Secret: ko.String("upload.fs.secret"),
})
if err != nil {
log.Fatalf("error initializing fs media store: %v", err)
@@ -572,11 +603,41 @@ func initEmailInbox(inboxRecord imodels.Inbox, msgStore inbox.MessageStore, usrS
return inbox, nil
}
// initLiveChatInbox initializes the live chat inbox.
func initLiveChatInbox(inboxRecord imodels.Inbox, msgStore inbox.MessageStore, usrStore inbox.UserStore) (inbox.Inbox, error) {
var config livechat.Config
// Load JSON data into Koanf.
if err := ko.Load(rawbytes.Provider([]byte(inboxRecord.Config)), kjson.Parser()); err != nil {
return nil, fmt.Errorf("loading config: %w", err)
}
if err := ko.UnmarshalWithConf("", &config, koanf.UnmarshalConf{Tag: "json"}); err != nil {
return nil, fmt.Errorf("unmarshalling `%s` %s config: %w", inboxRecord.Channel, inboxRecord.Name, err)
}
inbox, err := livechat.New(msgStore, usrStore, livechat.Opts{
ID: inboxRecord.ID,
Config: config,
Lo: initLogger("livechat_inbox"),
})
if err != nil {
return nil, fmt.Errorf("initializing `%s` inbox: `%s` error : %w", inboxRecord.Channel, inboxRecord.Name, err)
}
log.Printf("`%s` inbox successfully initialized", inboxRecord.Name)
return inbox, nil
}
// initializeInboxes handles inbox initialization.
func initializeInboxes(inboxR imodels.Inbox, msgStore inbox.MessageStore, usrStore inbox.UserStore) (inbox.Inbox, error) {
switch inboxR.Channel {
case "email":
return initEmailInbox(inboxR, msgStore, usrStore)
case "livechat":
return initLiveChatInbox(inboxR, msgStore, usrStore)
default:
return nil, fmt.Errorf("unknown inbox channel: %s", inboxR.Channel)
}
@@ -894,3 +955,12 @@ func getLogLevel(lvl string) logf.Level {
return logf.InfoLevel
}
}
// initRateLimit initializes the rate limiter.
func initRateLimit(redisClient *redis.Client) *ratelimit.Limiter {
var config ratelimit.Config
if err := ko.UnmarshalWithConf("rate_limit", &config, koanf.UnmarshalConf{Tag: "toml"}); err != nil {
log.Fatalf("error unmarshalling rate limit config: %v", err)
}
return ratelimit.New(redisClient, config)
}

View File

@@ -3,7 +3,6 @@ package main
import (
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
"github.com/abhinavxd/libredesk/internal/envelope"
umodels "github.com/abhinavxd/libredesk/internal/user/models"
realip "github.com/ferluci/fast-realip"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
@@ -42,12 +41,6 @@ func handleLogin(r *fastglue.Request) error {
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("user.accountDisabled"), nil))
}
// Set user availability status to online.
if err := app.user.UpdateAvailability(user.ID, umodels.Online); err != nil {
return sendErrorEnvelope(r, err)
}
user.AvailabilityStatus = umodels.Online
if err := app.auth.SaveSession(amodels.User{
ID: user.ID,
Email: user.Email.String,

View File

@@ -35,6 +35,7 @@ import (
"github.com/abhinavxd/libredesk/internal/inbox"
"github.com/abhinavxd/libredesk/internal/media"
"github.com/abhinavxd/libredesk/internal/oidc"
"github.com/abhinavxd/libredesk/internal/ratelimit"
"github.com/abhinavxd/libredesk/internal/role"
"github.com/abhinavxd/libredesk/internal/setting"
"github.com/abhinavxd/libredesk/internal/tag"
@@ -54,7 +55,8 @@ var (
ko = koanf.New(".")
ctx = context.Background()
appName = "libredesk"
frontendDir = "frontend/dist"
frontendDir = "frontend/dist/main"
widgetDir = "frontend/dist/widget"
// Injected at build time.
buildString string
@@ -94,9 +96,12 @@ type App struct {
customAttribute *customAttribute.Manager
report *report.Manager
webhook *webhook.Manager
rateLimit *ratelimit.Limiter
// Global state that stores data on an available app update.
update *AppUpdate
// Flag to indicate if app restart is required for settings to take effect.
restartRequired bool
sync.Mutex
}
@@ -201,14 +206,20 @@ func main() {
sla = initSLA(db, team, settings, businessHours, notifier, template, user, i18n)
conversation = initConversations(i18n, sla, status, priority, wsHub, notifier, db, inbox, user, team, media, settings, csat, automation, template, webhook)
autoassigner = initAutoAssigner(team, user, conversation)
rateLimiter = initRateLimit(rdb)
)
wsHub.SetConversationStore(conversation)
automation.SetConversationStore(conversation)
// Start inboxes.
startInboxes(ctx, inbox, conversation, user)
go automation.Run(ctx, automationWorkers)
go autoassigner.Run(ctx, autoAssignInterval)
go conversation.Run(ctx, messageIncomingQWorkers, messageOutgoingQWorkers, messageOutgoingScanInterval)
go conversation.RunUnsnoozer(ctx, unsnoozeInterval)
go conversation.RunContinuity(ctx)
go webhook.Run(ctx)
go notifier.Run(ctx)
go sla.Run(ctx, slaEvaluationInterval)
@@ -239,7 +250,7 @@ func main() {
activityLog: initActivityLog(db, i18n),
customAttribute: initCustomAttribute(db, i18n),
authz: initAuthz(i18n),
view: initView(db),
view: initView(db, i18n),
report: initReport(db, i18n),
csat: initCSAT(db, i18n),
search: initSearch(db, i18n),
@@ -248,6 +259,7 @@ func main() {
macro: initMacro(db, i18n),
ai: initAI(db, i18n),
webhook: webhook,
rateLimit: rateLimiter,
}
app.consts.Store(constants)

View File

@@ -143,45 +143,51 @@ func handleMediaUpload(r *fastglue.Request) error {
}
// handleServeMedia serves uploaded media.
// Supports both authenticated agent access and unauthenticated access via signed URLs.
func handleServeMedia(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
uuid = r.RequestCtx.UserValue("uuid").(string)
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
)
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
// Fetch media from DB.
media, err := app.media.Get(0, strings.TrimPrefix(uuid, thumbPrefix))
if err != nil {
return sendErrorEnvelope(r, err)
}
// Check if the user has permission to access the linked model.
allowed, err := app.authz.EnforceMediaAccess(user, media.Model.String)
if err != nil {
return sendErrorEnvelope(r, err)
}
// For messages, check access to the conversation this message is part of.
if media.Model.String == "messages" {
conversation, err := app.conversation.GetConversationByMessageID(media.ModelID.Int)
// Check if user is authenticated (agent access)
auser := r.RequestCtx.UserValue("user")
if auser != nil {
// Authenticated.
user, err := app.user.GetAgent(auser.(amodels.User).ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
allowed, err = app.authz.EnforceConversationAccess(user, conversation)
// Fetch media from DB.
media, err := app.media.Get(0, strings.TrimPrefix(uuid, thumbPrefix))
if err != nil {
return sendErrorEnvelope(r, err)
}
}
if !allowed {
return r.SendErrorEnvelope(http.StatusUnauthorized, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.UnauthorizedError)
// Check if the user has permission to access the linked model.
allowed, err := app.authz.EnforceMediaAccess(user, media.Model.String)
if err != nil {
return sendErrorEnvelope(r, err)
}
// For messages, check access to the conversation this message is part of.
if media.Model.String == "messages" {
conversation, err := app.conversation.GetConversationByMessageID(media.ModelID.Int)
if err != nil {
return sendErrorEnvelope(r, err)
}
allowed, err = app.authz.EnforceConversationAccess(user, conversation)
if err != nil {
return sendErrorEnvelope(r, err)
}
}
if !allowed {
return r.SendErrorEnvelope(http.StatusUnauthorized, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.UnauthorizedError)
}
}
// If no authenticated user, the middleware has already verified the request signature serve the file.
consts := app.consts.Load().(*constants)
switch consts.UploadProvider {
case "fs":

View File

@@ -2,10 +2,14 @@ package main
import (
"strconv"
"strings"
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
authzModels "github.com/abhinavxd/libredesk/internal/authz/models"
cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
"github.com/abhinavxd/libredesk/internal/envelope"
medModels "github.com/abhinavxd/libredesk/internal/media/models"
umodels "github.com/abhinavxd/libredesk/internal/user/models"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
@@ -17,6 +21,7 @@ type messageReq struct {
To []string `json:"to"`
CC []string `json:"cc"`
BCC []string `json:"bcc"`
SenderType string `json:"sender_type"`
}
// handleGetMessages returns messages for a conversation.
@@ -41,7 +46,7 @@ func handleGetMessages(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
messages, pageSize, err := app.conversation.GetConversationMessages(uuid, page, pageSize)
messages, pageSize, err := app.conversation.GetConversationMessages(uuid, []string{cmodels.MessageIncoming, cmodels.MessageOutgoing, cmodels.MessageActivity}, nil, page, pageSize)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -52,10 +57,11 @@ func handleGetMessages(r *fastglue.Request) error {
for j := range messages[i].Attachments {
messages[i].Attachments[j].URL = app.media.GetURL(messages[i].Attachments[j].UUID)
}
// Redact CSAT survey link
messages[i].CensorCSATContent()
}
// Process CSAT status for all messages (will only affect CSAT messages)
app.conversation.ProcessCSATStatus(messages)
return r.SendEnvelope(envelope.PageResults{
Total: total,
Results: messages,
@@ -89,8 +95,10 @@ func handleGetMessage(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
// Redact CSAT survey link
message.CensorCSATContent()
// Process CSAT status for the message (will only affect CSAT messages)
messages := []cmodels.Message{message}
app.conversation.ProcessCSATStatus(messages)
message = messages[0]
for j := range message.Attachments {
message.Attachments[j].URL = app.media.GetURL(message.Attachments[j].UUID)
@@ -99,7 +107,7 @@ func handleGetMessage(r *fastglue.Request) error {
return r.SendEnvelope(message)
}
// handleRetryMessage changes message status so it can be retried for sending.
// handleRetryMessage changes message status to `pending`, so it's enqueued for sending.
func handleRetryMessage(r *fastglue.Request) error {
var (
app = r.Context.(*App)
@@ -150,7 +158,41 @@ func handleSendMessage(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
// Make sure the inbox is enabled.
inbox, err := app.inbox.GetDBRecord(conv.InboxID)
if err != nil {
return sendErrorEnvelope(r, err)
}
if !inbox.Enabled {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.disabled", "name", "{globals.terms.inbox}"), nil, envelope.InputError)
}
// Prepare attachments.
if req.SenderType != umodels.UserTypeAgent && req.SenderType != umodels.UserTypeContact {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`sender_type`"), nil, envelope.InputError)
}
// Contacts cannot send private messages
if req.SenderType == umodels.UserTypeContact && req.Private {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.badRequest"), nil, envelope.InputError)
}
// Check if user has permission to send messages as contact
if req.SenderType == umodels.UserTypeContact {
parts := strings.Split(authzModels.PermMessagesWriteAsContact, ":")
if len(parts) != 2 {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorChecking", "name", "{globals.terms.permission}"), nil))
}
ok, err := app.authz.Enforce(user, parts[0], parts[1])
if err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorChecking", "name", "{globals.terms.permission}"), nil))
}
if !ok {
return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.PermissionError)
}
}
// Get media for all attachments.
var media = make([]medModels.Media, 0, len(req.Attachments))
for _, id := range req.Attachments {
m, err := app.media.Get(id, "")
@@ -161,6 +203,16 @@ func handleSendMessage(r *fastglue.Request) error {
media = append(media, m)
}
// Create contact message.
if req.SenderType == umodels.UserTypeContact {
message, err := app.conversation.CreateContactMessage(media, int(conv.ContactID), cuuid, req.Message, cmodels.ContentTypeHTML)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(message)
}
// Send private note.
if req.Private {
message, err := app.conversation.SendPrivateNote(media, user.ID, cuuid, req.Message)
if err != nil {
@@ -168,7 +220,8 @@ func handleSendMessage(r *fastglue.Request) error {
}
return r.SendEnvelope(message)
}
message, err := app.conversation.SendReply(media, conv.InboxID, user.ID, cuuid, req.Message, req.To, req.CC, req.BCC, map[string]any{} /**meta**/)
message, err := app.conversation.QueueReply(media, conv.InboxID, user.ID, conv.ContactID, cuuid, req.Message, req.To, req.CC, req.BCC, map[string]any{} /**meta**/)
if err != nil {
return sendErrorEnvelope(r, err)
}

View File

@@ -97,6 +97,23 @@ func auth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
return func(r *fastglue.Request) error {
var app = r.Context.(*App)
// For media uploads, check if signature is provided in the query parameters, if so, verify it.
path := string(r.RequestCtx.Path())
if strings.HasPrefix(path, "/uploads/") {
signature := string(r.RequestCtx.QueryArgs().Peek("signature"))
expires := string(r.RequestCtx.QueryArgs().Peek("expires"))
if signature != "" && expires != "" {
if err := app.media.VerifySignature(r); err != nil {
app.lo.Error("error verifying media signature", "error",
err, "path", string(r.RequestCtx.Path()), "query", string(r.RequestCtx.QueryArgs().QueryString()))
return r.SendErrorEnvelope(http.StatusUnauthorized, "signature verification failed", nil, envelope.PermissionError)
}
return handler(r)
}
// If no signature, continue with normal authentication.
}
// Authenticate user using shared authentication logic
user, err := authenticateUser(r, app)
if err != nil {

View File

@@ -11,16 +11,6 @@ import (
"github.com/zerodha/fastglue"
)
// handleGetAllEnabledOIDC returns all enabled OIDC records
func handleGetAllEnabledOIDC(r *fastglue.Request) error {
app := r.Context.(*App)
out, err := app.oidc.GetAllEnabled()
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(out)
}
// handleGetAllOIDC returns all OIDC records
func handleGetAllOIDC(r *fastglue.Request) error {
app := r.Context.(*App)
@@ -74,10 +64,10 @@ func handleCreateOIDC(r *fastglue.Request) error {
if err := reloadAuth(app); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.couldNotReload", "name", "OIDC"), nil, envelope.GeneralError)
}
// Clear client secret before returning
createdOIDC.ClientSecret = strings.Repeat(stringutil.PasswordDummy, 10)
return r.SendEnvelope(createdOIDC)
}
@@ -110,10 +100,10 @@ func handleUpdateOIDC(r *fastglue.Request) error {
if err := reloadAuth(app); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.couldNotReload", "name", "OIDC"), nil, envelope.GeneralError)
}
// Clear client secret before returning
updatedOIDC.ClientSecret = strings.Repeat(stringutil.PasswordDummy, 10)
return r.SendEnvelope(updatedOIDC)
}

View File

@@ -31,6 +31,8 @@ func handleGetGeneralSettings(r *fastglue.Request) error {
settings["app.update"] = app.update
// Set app version.
settings["app.version"] = versionString
// Set restart required flag.
settings["app.restart_required"] = app.restartRequired
return r.SendEnvelope(settings)
}
@@ -45,6 +47,11 @@ func handleUpdateGeneralSettings(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.badRequest"), nil, envelope.InputError)
}
// Get current language before update.
app.Lock()
oldLang := ko.String("app.lang")
app.Unlock()
// Remove any trailing slash `/` from the root url.
req.RootURL = strings.TrimRight(req.RootURL, "/")
@@ -55,6 +62,17 @@ func handleUpdateGeneralSettings(r *fastglue.Request) error {
if err := reloadSettings(app); err != nil {
return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.couldNotReload", "name", app.i18n.T("globals.terms.setting")), nil)
}
// Check if language changed and reload i18n if needed.
app.Lock()
newLang := ko.String("app.lang")
if oldLang != newLang {
app.lo.Info("language changed, reloading i18n", "old_lang", oldLang, "new_lang", newLang)
app.i18n = initI18n(app.fs)
app.lo.Info("reloaded i18n", "old_lang", oldLang, "new_lang", newLang)
}
app.Unlock()
if err := reloadTemplates(app); err != nil {
return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.couldNotReload", "name", app.i18n.T("globals.terms.setting")), nil)
}
@@ -109,6 +127,7 @@ func handleUpdateEmailNotificationSettings(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.invalidFromAddress"), nil, envelope.InputError)
}
// If empty then retain previous password.
if req.Password == "" {
req.Password = cur.Password
}
@@ -117,6 +136,10 @@ func handleUpdateEmailNotificationSettings(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
// No reload implemented, so user has to restart the app.
// Email notification settings require app restart to take effect.
app.Lock()
app.restartRequired = true
app.Unlock()
return r.SendEnvelope(true)
}

View File

@@ -83,7 +83,7 @@ func handleUpdateTeam(r *fastglue.Request) error {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil))
}
updatedTeam, err := app.team.Update(id, req.Name, req.Timezone, req.ConversationAssignmentType, req.BusinessHoursID, req.SLAPolicyID, req.Emoji.String, req.MaxAutoAssignedConversations);
updatedTeam, err := app.team.Update(id, req.Name, req.Timezone, req.ConversationAssignmentType, req.BusinessHoursID, req.SLAPolicyID, req.Emoji.String, req.MaxAutoAssignedConversations)
if err != nil {
return sendErrorEnvelope(r, err)
}

View File

@@ -35,6 +35,8 @@ var migList = []migFunc{
{"v0.5.0", migrations.V0_5_0},
{"v0.6.0", migrations.V0_6_0},
{"v0.7.0", migrations.V0_7_0},
{"v0.7.4", migrations.V0_7_4},
{"v0.9.0", migrations.V0_9_0},
}
// upgrade upgrades the database to the current version by running SQL migration files

View File

@@ -26,34 +26,38 @@ const (
maxAvatarSizeMB = 2
)
// Request structs for user-related endpoints
// UpdateAvailabilityRequest represents the request to update user availability
type UpdateAvailabilityRequest struct {
type updateAvailabilityRequest struct {
Status string `json:"status"`
}
// ResetPasswordRequest represents the password reset request
type ResetPasswordRequest struct {
type resetPasswordRequest struct {
Email string `json:"email"`
}
// SetPasswordRequest represents the set password request
type SetPasswordRequest struct {
type setPasswordRequest struct {
Token string `json:"token"`
Password string `json:"password"`
}
// AvailabilityRequest represents the request to update agent availability
type AvailabilityRequest struct {
type availabilityRequest struct {
Status string `json:"status"`
}
type agentReq struct {
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Email string `json:"email"`
SendWelcomeEmail bool `json:"send_welcome_email"`
Teams []string `json:"teams"`
Roles []string `json:"roles"`
Enabled bool `json:"enabled"`
AvailabilityStatus string `json:"availability_status"`
NewPassword string `json:"new_password,omitempty"`
}
// handleGetAgents returns all agents.
func handleGetAgents(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
var app = r.Context.(*App)
agents, err := app.user.GetAgents()
if err != nil {
return sendErrorEnvelope(r, err)
@@ -73,9 +77,7 @@ func handleGetAgentsCompact(r *fastglue.Request) error {
// handleGetAgent returns an agent.
func handleGetAgent(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
var app = r.Context.(*App)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
@@ -93,7 +95,7 @@ func handleUpdateAgentAvailability(r *fastglue.Request) error {
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
ip = realip.FromRequest(r.RequestCtx)
availReq AvailabilityRequest
availReq availabilityRequest
)
// Decode JSON request
@@ -101,6 +103,7 @@ func handleUpdateAgentAvailability(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
// Fetch entire agent
agent, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
@@ -108,10 +111,10 @@ func handleUpdateAgentAvailability(r *fastglue.Request) error {
// Same status?
if agent.AvailabilityStatus == availReq.Status {
return r.SendEnvelope(true)
return r.SendEnvelope(agent)
}
// Update availability status.
// Update availability status
if err := app.user.UpdateAvailability(auser.ID, availReq.Status); err != nil {
return sendErrorEnvelope(r, err)
}
@@ -123,21 +126,22 @@ func handleUpdateAgentAvailability(r *fastglue.Request) error {
}
}
return r.SendEnvelope(true)
// Fetch updated agent and return
agent, err = app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(agent)
}
// handleGetCurrentAgentTeams returns the teams of an agent.
// handleGetCurrentAgentTeams returns the teams of current agent.
func handleGetCurrentAgentTeams(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
agent, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
teams, err := app.team.GetUserTeams(agent.ID)
teams, err := app.team.GetUserTeams(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -150,11 +154,6 @@ func handleUpdateCurrentAgent(r *fastglue.Request) error {
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
agent, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
form, err := r.RequestCtx.MultipartForm()
if err != nil {
app.lo.Error("error parsing form data", "error", err)
@@ -165,54 +164,53 @@ func handleUpdateCurrentAgent(r *fastglue.Request) error {
// Upload avatar?
if ok && len(files) > 0 {
if err := uploadUserAvatar(r, &agent, files); err != nil {
agent, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
if err := uploadUserAvatar(r, agent, files); err != nil {
return sendErrorEnvelope(r, err)
}
}
return r.SendEnvelope(true)
// Fetch updated agent and return.
agent, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(agent)
}
// handleCreateAgent creates a new agent.
func handleCreateAgent(r *fastglue.Request) error {
var (
app = r.Context.(*App)
user = models.User{}
app = r.Context.(*App)
req = agentReq{}
)
if err := r.Decode(&user, "json"); err != nil {
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if user.Email.String == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`email`"), nil, envelope.InputError)
}
user.Email = null.StringFrom(strings.TrimSpace(strings.ToLower(user.Email.String)))
if !stringutil.ValidEmail(user.Email.String) {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`email`"), nil, envelope.InputError)
// Validate agent request
if err := validateAgentRequest(r, &req); err != nil {
return err
}
if user.Roles == nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`role`"), nil, envelope.InputError)
}
if user.FirstName == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`first_name`"), nil, envelope.InputError)
}
if err := app.user.CreateAgent(&user); err != nil {
agent, err := app.user.CreateAgent(req.FirstName, req.LastName, req.Email, req.Roles)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Upsert user teams.
if len(user.Teams) > 0 {
if err := app.team.UpsertUserTeams(user.ID, user.Teams.Names()); err != nil {
return sendErrorEnvelope(r, err)
}
if len(req.Teams) > 0 {
app.team.UpsertUserTeams(agent.ID, req.Teams)
}
if user.SendWelcomeEmail {
if req.SendWelcomeEmail {
// Generate reset token.
resetToken, err := app.user.SetResetPasswordToken(user.ID)
resetToken, err := app.user.SetResetPasswordToken(agent.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -220,31 +218,36 @@ func handleCreateAgent(r *fastglue.Request) error {
// Render template and send email.
content, err := app.tmpl.RenderInMemoryTemplate(tmpl.TmplWelcome, map[string]any{
"ResetToken": resetToken,
"Email": user.Email.String,
"Email": req.Email,
})
if err != nil {
app.lo.Error("error rendering template", "error", err)
return r.SendEnvelope(true)
}
if err := app.notifier.Send(notifier.Message{
RecipientEmails: []string{user.Email.String},
Subject: "Welcome to Libredesk",
RecipientEmails: []string{req.Email},
Subject: app.i18n.T("globals.messages.welcomeToLibredesk"),
Content: content,
Provider: notifier.ProviderEmail,
}); err != nil {
app.lo.Error("error sending notification message", "error", err)
return r.SendEnvelope(true)
}
}
return r.SendEnvelope(true)
// Refetch agent as other details might've changed.
agent, err = app.user.GetAgent(agent.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(agent)
}
// handleUpdateAgent updates an agent.
func handleUpdateAgent(r *fastglue.Request) error {
var (
app = r.Context.(*App)
user = models.User{}
req = agentReq{}
auser = r.RequestCtx.UserValue("user").(amodels.User)
ip = realip.FromRequest(r.RequestCtx)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
@@ -253,25 +256,13 @@ func handleUpdateAgent(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`id`"), nil, envelope.InputError)
}
if err := r.Decode(&user, "json"); err != nil {
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if user.Email.String == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`email`"), nil, envelope.InputError)
}
user.Email = null.StringFrom(strings.TrimSpace(strings.ToLower(user.Email.String)))
if !stringutil.ValidEmail(user.Email.String) {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`email`"), nil, envelope.InputError)
}
if user.Roles == nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`role`"), nil, envelope.InputError)
}
if user.FirstName == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`first_name`"), nil, envelope.InputError)
// Validate agent request
if err := validateAgentRequest(r, &req); err != nil {
return err
}
agent, err := app.user.GetAgent(id, "")
@@ -280,8 +271,8 @@ func handleUpdateAgent(r *fastglue.Request) error {
}
oldAvailabilityStatus := agent.AvailabilityStatus
// Update agent.
if err = app.user.UpdateAgent(id, user); err != nil {
// Update agent with individual fields
if err = app.user.UpdateAgent(id, req.FirstName, req.LastName, req.Email, req.Roles, req.Enabled, req.AvailabilityStatus, req.NewPassword); err != nil {
return sendErrorEnvelope(r, err)
}
@@ -289,18 +280,24 @@ func handleUpdateAgent(r *fastglue.Request) error {
defer app.authz.InvalidateUserCache(id)
// Create activity log if user availability status changed.
if oldAvailabilityStatus != user.AvailabilityStatus {
if err := app.activityLog.UserAvailability(auser.ID, auser.Email, user.AvailabilityStatus, ip, user.Email.String, id); err != nil {
if oldAvailabilityStatus != req.AvailabilityStatus {
if err := app.activityLog.UserAvailability(auser.ID, auser.Email, req.AvailabilityStatus, ip, req.Email, id); err != nil {
app.lo.Error("error creating activity log", "error", err)
}
}
// Upsert agent teams.
if err := app.team.UpsertUserTeams(id, user.Teams.Names()); err != nil {
if err := app.team.UpsertUserTeams(id, req.Teams); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
// Refetch agent and return.
agent, err = app.user.GetAgent(id, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(agent)
}
// handleDeleteAgent soft deletes an agent.
@@ -381,7 +378,7 @@ func handleResetPassword(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser, ok = r.RequestCtx.UserValue("user").(amodels.User)
resetReq ResetPasswordRequest
resetReq resetPasswordRequest
)
if ok && auser.ID > 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("user.userAlreadyLoggedIn"), nil, envelope.InputError)
@@ -399,7 +396,7 @@ func handleResetPassword(r *fastglue.Request) error {
agent, err := app.user.GetAgent(0, resetReq.Email)
if err != nil {
// Send 200 even if user not found, to prevent email enumeration.
return r.SendEnvelope("Reset password email sent successfully.")
return r.SendEnvelope(true)
}
token, err := app.user.SetResetPasswordToken(agent.ID)
@@ -434,7 +431,7 @@ func handleSetPassword(r *fastglue.Request) error {
var (
app = r.Context.(*App)
agent, ok = r.RequestCtx.UserValue("user").(amodels.User)
req = SetPasswordRequest{}
req setPasswordRequest
)
if ok && agent.ID > 0 {
@@ -457,13 +454,13 @@ func handleSetPassword(r *fastglue.Request) error {
}
// uploadUserAvatar uploads the user avatar.
func uploadUserAvatar(r *fastglue.Request, user *models.User, files []*multipart.FileHeader) error {
func uploadUserAvatar(r *fastglue.Request, user models.User, files []*multipart.FileHeader) error {
var app = r.Context.(*App)
fileHeader := files[0]
file, err := fileHeader.Open()
if err != nil {
app.lo.Error("error opening uploaded file", "error", err)
app.lo.Error("error opening uploaded file", "user_id", user.ID, "error", err)
return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorReading", "name", "{globals.terms.file}"), nil)
}
defer file.Close()
@@ -480,7 +477,7 @@ func uploadUserAvatar(r *fastglue.Request, user *models.User, files []*multipart
// Check file size
if bytesToMegabytes(srcFileSize) > maxAvatarSizeMB {
app.lo.Error("error uploaded file size is larger than max allowed", "size", bytesToMegabytes(srcFileSize), "max_allowed", maxAvatarSizeMB)
app.lo.Error("error uploaded file size is larger than max allowed", "user_id", user.ID, "size", bytesToMegabytes(srcFileSize), "max_allowed", maxAvatarSizeMB)
return envelope.NewError(
envelope.InputError,
app.i18n.Ts("media.fileSizeTooLarge", "size", fmt.Sprintf("%dMB", maxAvatarSizeMB)),
@@ -497,23 +494,25 @@ func uploadUserAvatar(r *fastglue.Request, user *models.User, files []*multipart
meta := []byte("{}")
media, err := app.media.UploadAndInsert(srcFileName, srcContentType, contentID, linkedModel, linkedID, file, int(srcFileSize), disposition, meta)
if err != nil {
app.lo.Error("error uploading file", "error", err)
app.lo.Error("error uploading file", "user_id", user.ID, "error", err)
return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorUploading", "name", "{globals.terms.file}"), nil)
}
// Delete current avatar.
if user.AvatarURL.Valid {
fileName := filepath.Base(user.AvatarURL.String)
app.media.Delete(fileName)
if err := app.media.Delete(fileName); err != nil {
app.lo.Error("error deleting user avatar", "user_id", user.ID, "error", err)
}
}
// Save file path.
path, err := stringutil.GetPathFromURL(media.URL)
if err != nil {
app.lo.Debug("error getting path from URL", "url", media.URL, "error", err)
app.lo.Debug("error getting path from URL", "user_id", user.ID, "url", media.URL, "error", err)
return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorUploading", "name", "{globals.terms.file}"), nil)
}
fmt.Println("path", path)
if err := app.user.UpdateAvatar(user.ID, path); err != nil {
return sendErrorEnvelope(r, err)
}
@@ -577,3 +576,28 @@ func handleRevokeAPIKey(r *fastglue.Request) error {
return r.SendEnvelope(true)
}
// validateAgentRequest validates common agent request fields and normalizes the email
func validateAgentRequest(r *fastglue.Request, req *agentReq) error {
var app = r.Context.(*App)
// Normalize email
req.Email = strings.TrimSpace(strings.ToLower(req.Email))
if req.Email == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`email`"), nil, envelope.InputError)
}
if !stringutil.ValidEmail(req.Email) {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`email`"), nil, envelope.InputError)
}
if req.Roles == nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`role`"), nil, envelope.InputError)
}
if req.FirstName == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`first_name`"), nil, envelope.InputError)
}
return nil
}

167
cmd/widget_middleware.go Normal file
View File

@@ -0,0 +1,167 @@
package main
import (
"fmt"
"strconv"
"strings"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/inbox/channel/livechat"
imodels "github.com/abhinavxd/libredesk/internal/inbox/models"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
const (
// Context keys for storing authenticated widget data
ctxWidgetClaims = "widget_claims"
ctxWidgetInboxID = "widget_inbox_id"
ctxWidgetContactID = "widget_contact_id"
ctxWidgetInbox = "widget_inbox"
// Header sent in every widget request to identify the inbox
hdrWidgetInboxID = "X-Libredesk-Inbox-ID"
)
// widgetAuth middleware authenticates widget requests using JWT and inbox validation.
// It always validates the inbox from X-Libredesk-Inbox-ID header, and conditionally validates JWT.
// For /conversations/init without JWT, it allows visitor creation while still validating inbox.
func widgetAuth(next func(*fastglue.Request) error) func(*fastglue.Request) error {
return func(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
// Always extract and validate inbox_id from custom header
inboxIDHeader := string(r.RequestCtx.Request.Header.Peek(hdrWidgetInboxID))
if inboxIDHeader == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "{globals.terms.inbox}"), nil, envelope.InputError)
}
inboxID, err := strconv.Atoi(inboxIDHeader)
if err != nil || inboxID <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "{globals.terms.inbox}"), nil, envelope.InputError)
}
// Always fetch and validate inbox
inbox, err := app.inbox.GetDBRecord(inboxID)
if err != nil {
app.lo.Error("error fetching inbox", "inbox_id", inboxID, "error", err)
return sendErrorEnvelope(r, err)
}
if !inbox.Enabled {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.disabled", "name", "{globals.terms.inbox}"), nil, envelope.InputError)
}
// Check if inbox is the correct type for widget requests
if inbox.Channel != livechat.ChannelLiveChat {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.inbox}"), nil, envelope.InputError)
}
// Always store inbox data in context
r.RequestCtx.SetUserValue(ctxWidgetInboxID, inboxID)
r.RequestCtx.SetUserValue(ctxWidgetInbox, inbox)
// Extract JWT from Authorization header (Bearer token)
authHeader := string(r.RequestCtx.Request.Header.Peek("Authorization"))
// For init endpoint, allow requests without JWT (visitor creation)
if authHeader == "" && strings.Contains(string(r.RequestCtx.Path()), "/conversations/init") {
return next(r)
}
// For all other requests, require JWT
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
return r.SendErrorEnvelope(fasthttp.StatusUnauthorized, app.i18n.T("globals.terms.unAuthorized"), nil, envelope.UnauthorizedError)
}
jwtToken := strings.TrimPrefix(authHeader, "Bearer ")
// Verify JWT using inbox secret
claims, err := verifyStandardJWT(jwtToken, inbox.Secret.String)
if err != nil {
app.lo.Error("invalid JWT", "jwt", jwtToken, "error", err)
return r.SendErrorEnvelope(fasthttp.StatusUnauthorized, app.i18n.T("globals.terms.unAuthorized"), nil, envelope.UnauthorizedError)
}
// Resolve user/contact ID from JWT claims
contactID, err := resolveUserIDFromClaims(app, claims)
if err != nil {
envErr, ok := err.(envelope.Error)
if ok && envErr.ErrorType != envelope.NotFoundError {
app.lo.Error("error resolving user ID from JWT claims", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.user}"), nil, envelope.GeneralError)
}
}
// Store authenticated data in request context for downstream handlers
r.RequestCtx.SetUserValue(ctxWidgetClaims, claims)
r.RequestCtx.SetUserValue(ctxWidgetContactID, contactID)
return next(r)
}
}
// Helper functions to extract authenticated data from request context
// getWidgetInboxID extracts inbox ID from request context
func getWidgetInboxID(r *fastglue.Request) (int, error) {
val := r.RequestCtx.UserValue(ctxWidgetInboxID)
if val == nil {
return 0, fmt.Errorf("widget middleware not applied: missing inbox ID in context")
}
inboxID, ok := val.(int)
if !ok {
return 0, fmt.Errorf("invalid inbox ID type in context")
}
return inboxID, nil
}
// getWidgetContactID extracts contact ID from request context
func getWidgetContactID(r *fastglue.Request) (int, error) {
val := r.RequestCtx.UserValue(ctxWidgetContactID)
if val == nil {
return 0, fmt.Errorf("widget middleware not applied: missing contact ID in context")
}
contactID, ok := val.(int)
if !ok {
return 0, fmt.Errorf("invalid contact ID type in context")
}
return contactID, nil
}
// getWidgetInbox extracts inbox model from request context
func getWidgetInbox(r *fastglue.Request) (imodels.Inbox, error) {
val := r.RequestCtx.UserValue(ctxWidgetInbox)
if val == nil {
return imodels.Inbox{}, fmt.Errorf("widget middleware not applied: missing inbox in context")
}
inbox, ok := val.(imodels.Inbox)
if !ok {
return imodels.Inbox{}, fmt.Errorf("invalid inbox type in context")
}
return inbox, nil
}
// getWidgetClaimsOptional extracts JWT claims from request context, returns nil if not set
func getWidgetClaimsOptional(r *fastglue.Request) *Claims {
val := r.RequestCtx.UserValue(ctxWidgetClaims)
if val == nil {
return nil
}
if claims, ok := val.(Claims); ok {
return &claims
}
return nil
}
// rateLimitWidget applies rate limiting to widget endpoints.
func rateLimitWidget(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
return func(r *fastglue.Request) error {
app := r.Context.(*App)
if err := app.rateLimit.CheckWidgetLimit(r.RequestCtx); err != nil {
return err
}
return handler(r)
}
}

288
cmd/widget_ws.go Normal file
View File

@@ -0,0 +1,288 @@
package main
import (
"encoding/json"
"fmt"
"github.com/abhinavxd/libredesk/internal/inbox/channel/livechat"
"github.com/fasthttp/websocket"
"github.com/zerodha/fastglue"
)
// Widget WebSocket message types
const (
WidgetMsgTypeJoin = "join"
WidgetMsgTypeMessage = "message"
WidgetMsgTypeTyping = "typing"
WidgetMsgTypePing = "ping"
WidgetMsgTypePong = "pong"
WidgetMsgTypeError = "error"
WidgetMsgTypeNewMsg = "new_message"
WidgetMsgTypeStatus = "status"
WidgetMsgTypeJoined = "joined"
)
// WidgetMessage represents a message sent through the widget WebSocket
type WidgetMessage struct {
Type string `json:"type"`
JWT string `json:"jwt,omitempty"`
Data any `json:"data"`
}
type WidgetInboxJoinRequest struct {
InboxID int `json:"inbox_id"`
}
// WidgetMessageData represents a chat message through the widget
type WidgetMessageData struct {
ConversationUUID string `json:"conversation_uuid"`
Content string `json:"content"`
SenderName string `json:"sender_name,omitempty"`
SenderType string `json:"sender_type"`
Timestamp int64 `json:"timestamp"`
}
// WidgetTypingData represents typing indicator data
type WidgetTypingData struct {
ConversationUUID string `json:"conversation_uuid"`
IsTyping bool `json:"is_typing"`
}
// handleWidgetWS handles the widget WebSocket connection for live chat.
func handleWidgetWS(r *fastglue.Request) error {
var app = r.Context.(*App)
if err := upgrader.Upgrade(r.RequestCtx, func(conn *websocket.Conn) {
// To store client and live chat references for cleanup.
var client *livechat.Client
var liveChat *livechat.LiveChat
var inboxID int
// Clean up client when connection closes.
defer func() {
conn.Close()
if client != nil && liveChat != nil {
liveChat.RemoveClient(client)
close(client.Channel)
app.lo.Debug("cleaned up client on websocket disconnect", "client_id", client.ID)
}
}()
// Read messages from the WebSocket connection.
for {
var msg WidgetMessage
if err := conn.ReadJSON(&msg); err != nil {
app.lo.Debug("widget websocket connection closed", "error", err)
break
}
switch msg.Type {
// Inbox join request.
case WidgetMsgTypeJoin:
var joinedClient *livechat.Client
var joinedLiveChat *livechat.LiveChat
var joinedInboxID int
var err error
if joinedClient, joinedLiveChat, joinedInboxID, err = handleInboxJoin(app, conn, &msg); err != nil {
app.lo.Error("error handling widget join", "error", err)
sendWidgetError(conn, "Failed to join conversation")
continue
}
// Store the client, livechat, and inbox ID for cleanup and future use.
client = joinedClient
liveChat = joinedLiveChat
inboxID = joinedInboxID
// Typing.
case WidgetMsgTypeTyping:
if err := handleWidgetTyping(app, &msg); err != nil {
app.lo.Error("error handling widget typing", "error", err)
continue
}
// Ping.
case WidgetMsgTypePing:
// Update user's last active timestamp if JWT is provided and client has joined
if msg.JWT != "" && inboxID != 0 {
if claims, err := validateWidgetMessageJWT(app, msg.JWT, inboxID); err == nil {
if userID, err := resolveUserIDFromClaims(app, claims); err == nil {
if err := app.user.UpdateLastActive(userID); err != nil {
app.lo.Error("error updating user last active timestamp", "user_id", userID, "error", err)
} else {
app.lo.Debug("updated user last active timestamp", "user_id", userID)
}
}
}
}
if err := conn.WriteJSON(WidgetMessage{
Type: WidgetMsgTypePong,
}); err != nil {
app.lo.Error("error writing pong to widget client", "error", err)
}
}
}
}); err != nil {
app.lo.Error("error upgrading widget websocket connection", "error", err)
}
return nil
}
// handleInboxJoin handles a websocket join request for a live chat inbox.
func handleInboxJoin(app *App, conn *websocket.Conn, msg *WidgetMessage) (*livechat.Client, *livechat.LiveChat, int, error) {
joinDataBytes, err := json.Marshal(msg.Data)
if err != nil {
return nil, nil, 0, fmt.Errorf("invalid join data: %w", err)
}
var joinData WidgetInboxJoinRequest
if err := json.Unmarshal(joinDataBytes, &joinData); err != nil {
return nil, nil, 0, fmt.Errorf("invalid join data format: %w", err)
}
// Validate JWT with inbox secret
claims, err := validateWidgetMessageJWT(app, msg.JWT, joinData.InboxID)
if err != nil {
return nil, nil, 0, fmt.Errorf("JWT validation failed: %w", err)
}
// Resolve user ID.
userID, err := resolveUserIDFromClaims(app, claims)
if err != nil {
return nil, nil, 0, fmt.Errorf("failed to resolve user ID from claims: %w", err)
}
// Make sure inbox is active.
inbox, err := app.inbox.GetDBRecord(joinData.InboxID)
if err != nil {
return nil, nil, 0, fmt.Errorf("inbox not found: %w", err)
}
if !inbox.Enabled {
return nil, nil, 0, fmt.Errorf("inbox is not enabled")
}
// Get live chat inbox
lcInbox, err := app.inbox.Get(inbox.ID)
if err != nil {
return nil, nil, 0, fmt.Errorf("live chat inbox not found: %w", err)
}
// Assert type.
liveChat, ok := lcInbox.(*livechat.LiveChat)
if !ok {
return nil, nil, 0, fmt.Errorf("inbox is not a live chat inbox")
}
// Add client to live chat session
userIDStr := fmt.Sprintf("%d", userID)
client, err := liveChat.AddClient(userIDStr)
if err != nil {
app.lo.Error("error adding client to live chat", "error", err, "user_id", userIDStr)
return nil, nil, 0, err
}
// Start listening for messages from the live chat channel.
go func() {
for msgData := range client.Channel {
if err := conn.WriteMessage(websocket.TextMessage, msgData); err != nil {
app.lo.Error("error forwarding message to widget client", "error", err)
return
}
}
}()
// Send join confirmation
joinResp := WidgetMessage{
Type: WidgetMsgTypeJoined,
Data: map[string]string{
"message": "namaste!",
},
}
if err := conn.WriteJSON(joinResp); err != nil {
return nil, nil, 0, err
}
app.lo.Debug("widget client joined live chat", "user_id", userIDStr, "inbox_id", joinData.InboxID)
return client, liveChat, joinData.InboxID, nil
}
// handleWidgetTyping handles typing indicators
func handleWidgetTyping(app *App, msg *WidgetMessage) error {
typingDataBytes, err := json.Marshal(msg.Data)
if err != nil {
app.lo.Error("error marshalling typing data", "error", err)
return fmt.Errorf("invalid typing data: %w", err)
}
var typingData WidgetTypingData
if err := json.Unmarshal(typingDataBytes, &typingData); err != nil {
app.lo.Error("error unmarshalling typing data", "error", err)
return fmt.Errorf("invalid typing data format: %w", err)
}
// Get conversation to retrieve inbox ID for JWT validation
if typingData.ConversationUUID == "" {
return fmt.Errorf("conversation UUID is required for typing messages")
}
conversation, err := app.conversation.GetConversation(0, typingData.ConversationUUID)
if err != nil {
app.lo.Error("error fetching conversation for typing", "conversation_uuid", typingData.ConversationUUID, "error", err)
return fmt.Errorf("conversation not found: %w", err)
}
// Validate JWT with inbox secret
claims, err := validateWidgetMessageJWT(app, msg.JWT, conversation.InboxID)
if err != nil {
return fmt.Errorf("JWT validation failed: %w", err)
}
userID := claims.UserID
// Broadcast typing status to agents via conversation manager
// Set broadcastToWidgets=false to avoid echoing back to widget clients
app.conversation.BroadcastTypingToConversation(typingData.ConversationUUID, typingData.IsTyping, false)
app.lo.Debug("Broadcasted typing data from widget user to agents", "user_id", userID, "is_typing", typingData.IsTyping, "conversation_uuid", typingData.ConversationUUID)
return nil
}
// validateWidgetMessageJWT validates the incoming widget message JWT using inbox secret
func validateWidgetMessageJWT(app *App, jwtToken string, inboxID int) (Claims, error) {
if jwtToken == "" {
return Claims{}, fmt.Errorf("JWT token is empty")
}
if inboxID <= 0 {
return Claims{}, fmt.Errorf("inbox ID is required for JWT validation")
}
// Get inbox to retrieve secret for JWT verification
inbox, err := app.inbox.GetDBRecord(inboxID)
if err != nil {
return Claims{}, fmt.Errorf("inbox not found: %w", err)
}
if !inbox.Secret.Valid {
return Claims{}, fmt.Errorf("inbox secret not configured for JWT verification")
}
// Use the existing verifyStandardJWT function which properly validates with inbox secret
claims, err := verifyStandardJWT(jwtToken, inbox.Secret.String)
if err != nil {
return Claims{}, fmt.Errorf("JWT validation failed: %w", err)
}
return claims, nil
}
// sendWidgetError sends an error message to the widget client
func sendWidgetError(conn *websocket.Conn, message string) {
errorMsg := WidgetMessage{
Type: WidgetMsgTypeError,
Data: map[string]string{
"message": message,
},
}
conn.WriteJSON(errorMsg)
}

View File

@@ -119,6 +119,17 @@ timeout = "15s"
# How often to check for conversations to unsnooze
unsnooze_interval = "5m"
[conversation.continuity]
offline_threshold = "10m"
batch_check_interval = "5m"
max_messages_per_email = 10
min_email_interval = "15m"
[sla]
# How often to evaluate SLA compliance for conversations
evaluation_interval = "5m"
[rate_limit]
[rate_limit.widget]
enabled = true
requests_per_minute = 100

View File

@@ -1,30 +0,0 @@
# API getting started
You can access the Libredesk API to interact with your instance programmatically.
## Generating API keys
1. **Edit agent**: Go to Admin → Teammate → Agent → Edit
2. **Generate new API key**: An API Key and API Secret will be generated for the agent
3. **Save the credentials**: Keep both the API Key and API Secret secure
4. **Key management**: You can revoke / regenerate API keys at any time from the same page
## Using the API
LibreDesk supports two authentication schemes:
### Basic authentication
```bash
curl -X GET "https://your-libredesk-instance.com/api/endpoint" \
-H "Authorization: Basic <base64_encoded_key:secret>"
```
### Token authentication
```bash
curl -X GET "https://your-libredesk-instance.com/api/endpoint" \
-H "Authorization: token your_api_key:your_api_secret"
```
## API Documentation
Complete API documentation with available endpoints and examples coming soon.

View File

@@ -1,32 +0,0 @@
# Developer Setup
Libredesk is a monorepo with a Go backend and a Vue.js frontend. The frontend uses Shadcn for UI components.
### Pre-requisites
- go
- nodejs (if you are working on the frontend) and `pnpm`
- redis
- postgres database (>= 13)
### First time setup
Clone the repository:
```sh
git clone https://github.com/abhinavxd/libredesk.git
```
1. Copy `config.toml.sample` as `config.toml` and add your config.
2. Run `make` to build the libredesk binary. Once the binary is built, run `./libredesk --install` to run the DB setup and set the System user password.
### Running the Dev Environment
1. Run `make run-backend` to start the libredesk backend dev server on `:9000`.
2. Run `make run-frontend` to start the Vue frontend in dev mode using pnpm on `:8000`. Requests are proxied to the backend running on `:9000` check `vite.config.js` for the proxy config.
---
# Production Build
Run `make` to build the Go binary, build the Javascript frontend, and embed the static assets producing a single self-contained binary, `libredesk`.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 298 KiB

View File

@@ -1,17 +0,0 @@
# Introduction
Libredesk is an open-source, self-hosted customer support desk — single binary app.
<div style="border: 1px solid #ccc; padding: 2px; border-radius: 6px; box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); background-color: #f9f9f9;">
<a href="https://libredesk.io">
<img src="images/hero.png" alt="libredesk UI screenshot" style="display: block; margin: 0 auto; max-width: 100%; border-radius: 4px;" />
</a>
</div>
## Developers
Libredesk is licensed under AGPLv3. Contributions are welcome.
- Source code: [GitHub](https://github.com/abhinavxd/libredesk)
- Setup guide: [Developer setup](developer-setup.md)
- Stack: Go backend, Vue 3 frontend (Shadcn UI)

View File

@@ -1,65 +0,0 @@
# Installation
Libredesk is a single binary application that requires postgres and redis to run. You can install it using the binary or docker.
## Binary
1. Download the [latest release](https://github.com/abhinavxd/libredesk/releases) and extract the libredesk binary.
2. `./libredesk --install` to install the tables in the Postgres DB (⩾ 13) and set the System user password.
3. Run `./libredesk` and visit `http://localhost:9000` and login with the email `System` and the password you set during installation.
!!! Tip
To set the System user password during installation, set the environment variables:
`LIBREDESK_SYSTEM_USER_PASSWORD=xxxxxxxxxxx ./libredesk --install`
## Docker
The latest image is available on DockerHub at `libredesk/libredesk:latest`
The recommended method is to download the [docker-compose.yml](https://github.com/abhinavxd/libredesk/blob/main/docker-compose.yml) file, customize it for your environment and then to simply run `docker compose up -d`.
```shell
# Download the compose file and the sample config file in the current directory.
curl -LO https://github.com/abhinavxd/libredesk/raw/main/docker-compose.yml
curl -LO https://github.com/abhinavxd/libredesk/raw/main/config.sample.toml
# Copy the config.sample.toml to config.toml and edit it as needed.
cp config.sample.toml config.toml
# Run the services in the background.
docker compose up -d
# Setting System user password.
docker exec -it libredesk_app ./libredesk --set-system-user-password
```
Go to `http://localhost:9000` and login with the email `System` and the password you set using the `--set-system-user-password` command.
## Compiling from source
To compile the latest unreleased version (`main` branch):
1. Make sure `go`, `nodejs`, and `pnpm` are installed on your system.
2. `git clone git@github.com:abhinavxd/libredesk.git`
3. `cd libredesk && make`. This will generate the `libredesk` binary.
## Nginx
Libredesk uses websockets for real-time updates. If you are using Nginx, you need to add the following (or similar) configuration to your Nginx configuration file.
```nginx
client_max_body_size 100M;
location / {
proxy_pass http://localhost:9000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_cache_bypass $http_upgrade;
}
```

View File

@@ -1,57 +0,0 @@
# Setting up SSO
Libredesk supports external OpenID Connect providers (e.g., Google, Keycloak) for signing in users.
!!! note
User accounts must be created in Libredesk manually; signup is not supported.
## Generic Configuration Steps
Since each providers configuration might differ, consult your providers documentation for any additional or divergent settings.
1. Provider setup:
In your providers admin console, create a new OpenID Connect application/client. Retrieve:
- Client ID
- Client Secret
2. Libredesk configuration:
In Libredesk, navigate to Security > SSO and click New SSO and enter the following details:
- Provider URL (e.g., the URL of your OpenID provider)
- Client ID
- Client Secret
- A descriptive name for the connection
3. Redirect URL:
After saving, copy the generated Callback URL from Libredesk and add it as a valid redirect URI in your providers client settings.
## Provider Examples
#### Keycloak
1. Log in to your Keycloak Admin Console.
2. In Keycloak, navigate to Clients and click Create:
- Client ID (e.g., `libredesk-app`)
- Client Protocol: `openid-connect`
- Root URL and Web Origins: your app domain (e.g., `https://ticket.example.com`)
- Under Authentication flow, uncheck everything except the standard flow
- Click save
3. Go to the credentials tab:
- Ensure client authenticator is set to `Client Id and Secret`
- Note down the generated client secret
4. In Libredesk, go to Admin > Security > SSO and click New SSO:
- Provider URL (e.g., `https://keycloak.example.com/realms/yourrealm`)
- Name (e.g., `Keycloak`)
- Client ID
- Client secret
- Click save
5. After saving, click on the three dots and choose Edit to open the new SSO entry.
6. Copy the generated Callback URL from Libredesk.
7. Back in Keycloak, edit the client and add the Callback URL to Valid Redirect URIs:
- e.g., `https://ticket.example.com/api/v1/oidc/1/finish`

View File

@@ -1,60 +0,0 @@
# Templating
Templating in outgoing emails allows you to personalize content by embedding dynamic expressions like `{{ .Recipient.FullName }}`. These expressions reference fields from the conversation, contact, recipient, and author objects.
## Outgoing Email Template Expressions
If you want to customize the look of outgoing emails, you can do so in the Admin > Templates -> Outgoing Email Templates section. This template will be used for all outgoing emails including replies to conversations, notifications, and other system-generated emails.
### Conversation Variables
| Variable | Value |
|---------------------------------|--------------------------------------------------------|
| {{ .Conversation.ReferenceNumber }} | The unique reference number of the conversation |
| {{ .Conversation.Subject }} | The subject of the conversation |
| {{ .Conversation.Priority }} | The priority level of the conversation |
| {{ .Conversation.UUID }} | The unique identifier of the conversation |
### Contact Variables
| Variable | Value |
|------------------------------|------------------------------------|
| {{ .Contact.FirstName }} | First name of the contact/customer |
| {{ .Contact.LastName }} | Last name of the contact/customer |
| {{ .Contact.FullName }} | Full name of the contact/customer |
| {{ .Contact.Email }} | Email address of the contact/customer |
### Recipient Variables
| Variable | Value |
|--------------------------------|-----------------------------------|
| {{ .Recipient.FirstName }} | First name of the recipient |
| {{ .Recipient.LastName }} | Last name of the recipient |
| {{ .Recipient.FullName }} | Full name of the recipient |
| {{ .Recipient.Email }} | Email address of the recipient |
### Author Variables
| Variable | Value |
|------------------------------|-----------------------------------|
| {{ .Author.FirstName }} | First name of the message author |
| {{ .Author.LastName }} | Last name of the message author |
| {{ .Author.FullName }} | Full name of the message author |
| {{ .Author.Email }} | Email address of the message author |
### Example outgoing email template
```html
Dear {{ .Recipient.FirstName }},
{{ template "content" . }}
Best regards,
{{ .Author.FullName }}
---
Reference: {{ .Conversation.ReferenceNumber }}
```
Here, the `{{ template "content" . }}` serves as a placeholder for the body of the outgoing email. It will be replaced with the actual email content at the time of sending.
Similarly, the `{{ .Recipient.FirstName }}` expression will dynamically insert the recipient's first name when the email is sent.

View File

@@ -1,3 +0,0 @@
# Translations / Internationalization
You can help translate libreDesk into different languages by contributing here: [Libredesk Translation Project](https://crowdin.com/project/libredesk)

View File

@@ -1,18 +0,0 @@
# Upgrade
!!! warning "Warning"
Always take a backup of the Postgres database before upgrading Libredesk.
## Binary
- Stop running libredesk binary.
- Download the [latest release](https://github.com/abhinavxd/libredesk/releases) and extract the libredesk binary and overwrite the previous version.
- `./libredesk --upgrade` to upgrade an existing database schema. Upgrades are idempotent and running them multiple times have no side effects.
- Run `./libredesk` again.
## Docker
```shell
docker compose down app
docker compose pull
docker compose up app -d
```

View File

@@ -1,222 +0,0 @@
# Webhooks
Webhooks allow you to receive real-time HTTP notifications when specific events occur in your Libredesk instance. This enables you to integrate Libredesk with external systems and automate workflows based on conversation and message events.
## Overview
When a configured event occurs in Libredesk, a HTTP POST request is sent to the webhook URL you specify. The request contains a JSON payload with event details and relevant data.
## Webhook Configuration
1. Navigate to **Admin > Integrations > Webhooks** in your Libredesk dashboard
2. Click **Create Webhook**
3. Configure the following:
- **Name**: A descriptive name for your webhook
- **URL**: The endpoint URL where webhook payloads will be sent
- **Events**: Select which events you want to subscribe to
- **Secret**: Optional secret key for signature verification
- **Status**: Enable or disable the webhook
## Security
### Signature Verification
If you provide a secret key, webhook payloads will be signed using HMAC-SHA256. The signature is included in the `X-Signature-256` header in the format `sha256=<signature>`.
To verify the signature:
```python
import hmac
import hashlib
def verify_signature(payload, signature, secret):
expected_signature = hmac.new(
secret.encode('utf-8'),
payload,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(f"sha256={expected_signature}", signature)
```
### Headers
Each webhook request includes the following headers:
- `Content-Type`: `application/json`
- `User-Agent`: `Libredesk-Webhook/<libredesk_version_here>`
- `X-Signature-256`: HMAC signature (if secret is configured)
## Available Events
### Conversation Events
#### `conversation.created`
Triggered when a new conversation is created.
**Sample Payload:**
```json
{
"event": "conversation.created",
"timestamp": "2025-06-15T10:30:00Z",
"payload": {
"id": 123,
"created_at": "2025-06-15T10:30:00Z",
"updated_at": "2025-06-15T10:30:00Z",
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"contact_id": 456,
"inbox_id": 1,
"reference_number": "100",
"priority": "Medium",
"priority_id": 2,
"status": "Open",
"status_id": 1,
"subject": "Help with account setup",
"inbox_name": "Support",
"inbox_channel": "email",
"contact": {
"id": 456,
"first_name": "John",
"last_name": "Doe",
"email": "john.doe@example.com",
"type": "contact"
},
"custom_attributes": {},
"tags": []
}
}
```
#### `conversation.status_changed`
Triggered when a conversation's status is updated.
**Sample Payload:**
```json
{
"event": "conversation.status_changed",
"timestamp": "2025-06-15T10:35:00Z",
"payload": {
"conversation_uuid": "550e8400-e29b-41d4-a716-446655440000",
"previous_status": "Open",
"new_status": "Resolved",
"snooze_until": "",
"actor_id": 789
}
}
```
#### `conversation.assigned`
Triggered when a conversation is assigned to a user.
**Sample Payload:**
```json
{
"event": "conversation.assigned",
"timestamp": "2025-06-15T10:32:00Z",
"payload": {
"conversation_uuid": "550e8400-e29b-41d4-a716-446655440000",
"assigned_to": 789,
"actor_id": 789
}
}
```
#### `conversation.unassigned`
Triggered when a conversation is unassigned from a user.
**Sample Payload:**
```json
{
"event": "conversation.unassigned",
"timestamp": "2025-06-15T10:40:00Z",
"payload": {
"conversation_uuid": "550e8400-e29b-41d4-a716-446655440000",
"actor_id": 789
}
}
```
#### `conversation.tags_changed`
Triggered when tags are added or removed from a conversation.
**Sample Payload:**
```json
{
"event": "conversation.tags_changed",
"timestamp": "2025-06-15T10:45:00Z",
"payload": {
"conversation_uuid": "550e8400-e29b-41d4-a716-446655440000",
"previous_tags": ["bug", "priority"],
"new_tags": ["bug", "priority", "resolved"],
"actor_id": 789
}
}
```
### Message Events
#### `message.created`
Triggered when a new message is created in a conversation.
**Sample Payload:**
```json
{
"event": "message.created",
"timestamp": "2025-06-15T10:33:00Z",
"payload": {
"id": 987,
"created_at": "2025-06-15T10:33:00Z",
"updated_at": "2025-06-15T10:33:00Z",
"uuid": "123e4567-e89b-12d3-a456-426614174000",
"type": "outgoing",
"status": "sent",
"conversation_id": 123,
"content": "<p>Hello! How can I help you today?</p>",
"text_content": "Hello! How can I help you today?",
"content_type": "html",
"private": false,
"sender_id": 789,
"sender_type": "agent",
"attachments": []
}
}
```
#### `message.updated`
Triggered when an existing message is updated.
**Sample Payload:**
```json
{
"event": "message.updated",
"timestamp": "2025-06-15T10:34:00Z",
"payload": {
"id": 987,
"created_at": "2025-06-15T10:33:00Z",
"updated_at": "2025-06-15T10:34:00Z",
"uuid": "123e4567-e89b-12d3-a456-426614174000",
"type": "outgoing",
"status": "sent",
"conversation_id": 123,
"content": "<p>Hello! How can I help you today? (Updated)</p>",
"text_content": "Hello! How can I help you today? (Updated)",
"content_type": "html",
"private": false,
"sender_id": 789,
"sender_type": "agent",
"attachments": []
}
}
```
## Delivery and Retries
- Webhooks requests timeout can be configured in the `config.toml` file
- Failed deliveries are not automatically retried
- Webhook delivery runs in a background worker pool for better performance
- If the webhook queue is full (configurable in config.toml file), new events may be dropped
## Testing Webhooks
You can test your webhook configuration using tools like:
- [Webhook.site](https://webhook.site) - Generate a temporary URL to inspect webhook payloads

View File

@@ -1,38 +0,0 @@
site_name: Libredesk Docs
theme:
name: material
language: en
font:
text: Source Sans Pro
code: Roboto Mono
weights: [400, 700]
direction: ltr
palette:
primary: white
accent: red
features:
- navigation.indexes
- navigation.sections
- content.code.copy
extra:
search:
language: en
markdown_extensions:
- admonition
- codehilite
- toc:
permalink: true
nav:
- Introduction: index.md
- Getting Started:
- Installation: installation.md
- Upgrade Guide: upgrade.md
- Email Templates: templating.md
- SSO Setup: sso.md
- Webhooks: webhooks.md
- API Getting Started: api-getting-started.md
- Contributions:
- Developer Setup: developer-setup.md
- Translate Libredesk: translations.md

59
frontend/README-SETUP.md Normal file
View File

@@ -0,0 +1,59 @@
# Libredesk Frontend - Multi-App Setup
This frontend supports both the main Libredesk application and a chat widget as separate Vue applications sharing common UI components.
## Project Structure
```
frontend/
├── apps/
│ ├── main/ # Main Libredesk application
│ │ ├── src/
│ │ └── index.html
│ └── widget/ # Chat widget application
│ ├── src/
│ └── index.html
├── shared-ui/ # Shared UI components (shadcn/ui)
│ ├── components/
│ │ └── ui/ # shadcn/ui components
│ ├── lib/ # Utility functions
│ └── assets/ # Shared styles
└── package.json
```
## Development
Check Makefile for available commands.
## Shared UI Components
The `shared-ui` directory contains all the shadcn/ui components that can be used in both apps.
### Using Shared Components
```vue
<script setup>
import { Button } from '@shared-ui/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@shared-ui/components/ui/card'
import { Input } from '@shared-ui/components/ui/input'
</script>
<template>
<Card>
<CardHeader>
<CardTitle>Example Card</CardTitle>
</CardHeader>
<CardContent>
<Input placeholder="Type something..." />
<Button>Submit</Button>
</CardContent>
</Card>
</template>
```
### Path Aliases
- `@shared-ui` - Points to the shared-ui directory
- `@main` - Points to apps/main/src
- `@widget` - Points to apps/widget/src
- `@` - Points to the current app's src directory (context-dependent)

View File

@@ -88,8 +88,8 @@
@create-conversation="() => (openCreateConversationDialog = true)"
>
<div class="flex flex-col h-screen">
<!-- Show app update only in admin routes -->
<AppUpdate v-if="route.path.startsWith('/admin')" />
<!-- Show admin banner only in admin routes -->
<AdminBanner v-if="route.path.startsWith('/admin')" />
<!-- Common header for all pages -->
<PageHeader />
@@ -112,26 +112,25 @@
<script setup>
import { onMounted, ref } from 'vue'
import { RouterView } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { initWS } from '@/websocket.js'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useEmitter } from '@/composables/useEmitter'
import { handleHTTPError } from '@/utils/http'
import { useUserStore } from './stores/user'
import { initWS } from './websocket.js'
import { EMITTER_EVENTS } from './constants/emitterEvents.js'
import { useEmitter } from './composables/useEmitter'
import { handleHTTPError } from './utils/http'
import { useConversationStore } from './stores/conversation'
import { useInboxStore } from '@/stores/inbox'
import { useUsersStore } from '@/stores/users'
import { useTeamStore } from '@/stores/team'
import { useSlaStore } from '@/stores/sla'
import { useMacroStore } from '@/stores/macro'
import { useTagStore } from '@/stores/tag'
import { useCustomAttributeStore } from '@/stores/customAttributes'
import { useIdleDetection } from '@/composables/useIdleDetection'
import { useInboxStore } from './stores/inbox'
import { useUsersStore } from './stores/users'
import { useTeamStore } from './stores/team'
import { useSlaStore } from './stores/sla'
import { useMacroStore } from './stores/macro'
import { useTagStore } from './stores/tag'
import { useCustomAttributeStore } from './stores/customAttributes'
import { useIdleDetection } from './composables/useIdleDetection'
import PageHeader from './components/layout/PageHeader.vue'
import ViewForm from '@/features/view/ViewForm.vue'
import AppUpdate from '@/components/update/AppUpdate.vue'
import api from '@/api'
import AdminBanner from '@/components/banner/AdminBanner.vue'
import { toast as sooner } from 'vue-sonner'
import Sidebar from '@/components/sidebar/Sidebar.vue'
import Sidebar from '@main/components/sidebar/Sidebar.vue'
import Command from '@/features/command/CommandBox.vue'
import CreateConversation from '@/features/conversation/CreateConversation.vue'
import { Inbox, Shield, FileLineChart, BookUser } from 'lucide-vue-next'
@@ -147,9 +146,10 @@ import {
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider
} from '@/components/ui/sidebar'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import SidebarNavUser from '@/components/sidebar/SidebarNavUser.vue'
} from '@shared-ui/components/ui/sidebar'
import { Tooltip, TooltipContent, TooltipTrigger } from '@shared-ui/components/ui/tooltip'
import SidebarNavUser from '@main/components/sidebar/SidebarNavUser.vue'
import api from '@/api'
const route = useRoute()
const emitter = useEmitter()

View File

@@ -5,8 +5,8 @@
<script setup>
import { onMounted } from 'vue'
import { RouterView } from 'vue-router'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from './constants/emitterEvents.js'
import { useEmitter } from './composables/useEmitter'
import { toast as sooner } from 'vue-sonner'
const emitter = useEmitter()

View File

@@ -7,6 +7,6 @@
<script setup>
import { RouterView } from 'vue-router'
import { Toaster } from '@/components/ui/sonner'
import { TooltipProvider } from '@/components/ui/tooltip'
import { Toaster } from '@shared-ui/components/ui/sonner'
import { TooltipProvider } from '@shared-ui/components/ui/tooltip'
</script>

View File

@@ -122,7 +122,7 @@ const createOIDC = (data) =>
'Content-Type': 'application/json'
}
})
const getAllEnabledOIDC = () => http.get('/api/v1/oidc/enabled')
const getConfig = () => http.get('/api/v1/config')
const getAllOIDC = () => http.get('/api/v1/oidc')
const getOIDC = (id) => http.get(`/api/v1/oidc/${id}`)
const updateOIDC = (id, data) =>
@@ -514,7 +514,7 @@ export default {
updateSettings,
createOIDC,
getAllOIDC,
getAllEnabledOIDC,
getConfig,
getOIDC,
updateOIDC,
deleteOIDC,

View File

@@ -0,0 +1,63 @@
<template>
<div class="border-b">
<!-- Update notification -->
<div
v-if="appSettingsStore.settings['app.update']?.update?.is_new"
class="px-4 py-2.5 border-b border-border/50 last:border-b-0"
>
<div class="flex items-center gap-3">
<div class="flex-shrink-0">
<Download class="w-5 h-5 text-primary" />
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 text-sm text-foreground">
<span>{{ $t('update.newUpdateAvailable') }}</span>
<a
:href="appSettingsStore.settings['app.update'].update.url"
target="_blank"
rel="nofollow noreferrer"
class="font-semibold text-primary hover:text-primary/80 underline transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1"
>
{{ appSettingsStore.settings['app.update'].update.release_version }}
</a>
<span class="text-muted-foreground"></span>
<span class="text-muted-foreground">
{{ appSettingsStore.settings['app.update'].update.release_date }}
</span>
</div>
<!-- Update description -->
<div
v-if="appSettingsStore.settings['app.update'].update.description"
class="mt-2 text-xs text-muted-foreground"
>
{{ appSettingsStore.settings['app.update'].update.description }}
</div>
</div>
</div>
</div>
<!-- Restart required notification -->
<div
v-if="appSettingsStore.settings['app.restart_required']"
class="px-4 py-2.5 border-b border-border/50 last:border-b-0"
>
<div class="flex items-center gap-3">
<div class="flex-shrink-0">
<Info class="w-5 h-5 text-primary" />
</div>
<div class="min-w-0 flex-1">
<div class="text-sm text-foreground">
{{ $t('admin.banner.restartMessage') }}
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { Download, Info } from 'lucide-vue-next'
import { useAppSettingsStore } from '@/stores/appSettings'
const appSettingsStore = useAppSettingsStore()
</script>

View File

@@ -1,7 +1,7 @@
<template>
<Button
variant="ghost"
@click.prevent="onClose"
@click.stop="onClose"
size="xs"
class="text-gray-400 dark:text-gray-500 hover:text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800 w-6 h-6 p-0"
>
@@ -12,7 +12,7 @@
</template>
<script setup>
import { Button } from '@/components/ui/button'
import { Button } from '@shared-ui/components/ui/button'
import { X } from 'lucide-vue-next'
defineProps({

View File

@@ -42,8 +42,8 @@
<script setup>
import { computed } from 'vue'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
import { Avatar, AvatarImage, AvatarFallback } from '@shared-ui/components/ui/avatar'
import ComboBox from '@shared-ui/components/ui/combobox/ComboBox.vue'
const props = defineProps({
modelValue: [String, Number, Object],

View File

@@ -51,7 +51,7 @@ import {
TableHead,
TableHeader,
TableRow
} from '@/components/ui/table'
} from '@shared-ui/components/ui/table'
const { t } = useI18n()
const props = defineProps({

View File

@@ -102,14 +102,14 @@ import {
Check,
X
} from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Button } from '@shared-ui/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
} from '@shared-ui/components/ui/dropdown-menu'
import { Input } from '@shared-ui/components/ui/input'
import Placeholder from '@tiptap/extension-placeholder'
import Image from '@tiptap/extension-image'
import StarterKit from '@tiptap/starter-kit'
@@ -118,6 +118,8 @@ import Table from '@tiptap/extension-table'
import TableRow from '@tiptap/extension-table-row'
import TableCell from '@tiptap/extension-table-cell'
import TableHeader from '@tiptap/extension-table-header'
import { useTypingIndicator } from '@shared-ui/composables'
import { useConversationStore } from '@main/stores/conversation'
const textContent = defineModel('textContent', { default: '' })
const htmlContent = defineModel('htmlContent', { default: '' })
@@ -127,6 +129,7 @@ const linkUrl = ref('')
const props = defineProps({
placeholder: String,
insertContent: String,
messageType: String,
autoFocus: {
type: Boolean,
default: true
@@ -134,13 +137,19 @@ const props = defineProps({
aiPrompts: {
type: Array,
default: () => []
}
},
})
const emit = defineEmits(['send', 'aiPromptSelected'])
const emitPrompt = (key) => emit('aiPromptSelected', key)
// Set up typing indicator
const conversationStore = useConversationStore()
const { startTyping, stopTyping } = useTypingIndicator(conversationStore.sendTyping, {
get isPrivateMessage() { return props.messageType === 'private_note' }
})
// To preseve the table styling in emails, need to set the table style inline.
// Created these custom extensions to set the table style inline.
const CustomTable = Table.extend({
@@ -201,6 +210,8 @@ const editor = useEditor({
handleKeyDown: (view, event) => {
if (event.ctrlKey && event.key === 'Enter') {
emit('send')
// Stop typing when sending
stopTyping()
return true
}
}
@@ -211,6 +222,13 @@ const editor = useEditor({
htmlContent.value = editor.getHTML()
textContent.value = editor.getText()
isInternalUpdate.value = false
// Trigger typing indicator when user types
startTyping()
},
onBlur: () => {
// Stop typing when editor loses focus
stopTyping()
}
})

View File

@@ -52,8 +52,15 @@
<div class="flex-1">
<div v-if="modelFilter.field && modelFilter.operator">
<template v-if="modelFilter.operator !== 'set' && modelFilter.operator !== 'not set'">
<SelectTag
v-if="getFieldType(modelFilter) === FIELD_TYPE.MULTI_SELECT"
v-model="modelFilter.value"
:items="getFieldOptions(modelFilter)"
:placeholder="t('globals.messages.select', { name: t('globals.terms.tag', 2) })"
/>
<SelectComboBox
v-if="
v-else-if="
getFieldOptions(modelFilter).length > 0 &&
modelFilter.field === 'assigned_user_id'
"
@@ -94,8 +101,9 @@
<CloseButton :onClose="() => removeFilter(index)" />
</div>
<!-- Button Container -->
<div class="flex items-center justify-between pt-3">
<Button variant="ghost" size="sm" @click="addFilter" class="text-slate-600">
<Button variant="ghost" size="sm" @click.stop="addFilter" class="text-slate-600">
<Plus class="w-3 h-3 mr-1" />
{{
$t('globals.messages.add', {
@@ -104,15 +112,17 @@
}}
</Button>
<div class="flex gap-2" v-if="showButtons">
<Button variant="ghost" @click="clearFilters">{{ $t('globals.messages.reset') }}</Button>
<Button @click="applyFilters">{{ $t('globals.messages.apply') }}</Button>
<Button variant="ghost" @click.stop="clearFilters">
{{ $t('globals.messages.reset') }}
</Button>
<Button @click.stop="applyFilters">{{ $t('globals.messages.apply') }}</Button>
</div>
</div>
</div>
</template>
<script setup>
import { computed, onMounted, watch } from 'vue'
import { computed, onMounted, onUnmounted, watch } from 'vue'
import {
Select,
SelectContent,
@@ -120,13 +130,15 @@ import {
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
} from '@shared-ui/components/ui/select'
import { Plus } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Button } from '@shared-ui/components/ui/button'
import { Input } from '@shared-ui/components/ui/input'
import { useI18n } from 'vue-i18n'
import { FIELD_TYPE } from '@/constants/filterConfig'
import CloseButton from '@/components/button/CloseButton.vue'
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
import SelectTag from '@shared-ui/components/ui/select/SelectTag.vue'
const props = defineProps({
fields: {
@@ -150,12 +162,17 @@ onMounted(() => {
}
})
onUnmounted(() => {
// On unmounted set valid filters
modelValue.value = validFilters.value
})
const getModel = (field) => {
const fieldConfig = props.fields.find((f) => f.field === field)
return fieldConfig?.model || ''
}
// Set model for each filter
// Set model for each filter and the default value
watch(
() => modelValue.value,
(filters) => {
@@ -163,6 +180,15 @@ watch(
if (filter.field && !filter.model) {
filter.model = getModel(filter.field)
}
// Multi select need arrays as their default value
if (
filter.field &&
getFieldType(filter) === FIELD_TYPE.MULTI_SELECT &&
!Array.isArray(filter.value)
) {
filter.value = []
}
})
},
{ deep: true }
@@ -170,15 +196,20 @@ watch(
// Reset operator and value when field changes for a filter at a given index
watch(
() => modelValue.value.map((f) => f.field),
(newFields, oldFields) => {
newFields.forEach((field, index) => {
if (field !== oldFields[index]) {
modelValue.value[index].operator = ''
modelValue.value[index].value = ''
modelValue,
(newFilters, oldFilters) => {
// Skip first run
if (!oldFilters) return
newFilters.forEach((filter, index) => {
const oldFilter = oldFilters[index]
if (oldFilter && filter.field !== oldFilter.field) {
filter.operator = ''
filter.value = ''
}
})
}
},
{ deep: true }
)
const addFilter = () => {
@@ -197,7 +228,17 @@ const clearFilters = () => {
}
const validFilters = computed(() => {
return modelValue.value.filter((filter) => filter.field && filter.operator && filter.value)
return modelValue.value.filter((filter) => {
// For multi-select field type, allow empty array as a valid value
const field = props.fields.find((f) => f.field === filter.field)
const isMultiSelectField = field?.type === FIELD_TYPE.MULTI_SELECT
if (isMultiSelectField) {
return filter.field && filter.operator && filter.value !== undefined && filter.value !== null
}
return filter.field && filter.operator && filter.value
})
})
const getFieldOptions = (fieldValue) => {
@@ -209,4 +250,9 @@ const getFieldOperators = (modelFilter) => {
const field = props.fields.find((f) => f.field === modelFilter.field)
return field?.operators || []
}
const getFieldType = (modelFilter) => {
const field = props.fields.find((f) => f.field === modelFilter.field)
return field?.type || ''
}
</script>

View File

@@ -4,9 +4,9 @@
@click="handleClick">
<div class="flex items-center mb-2">
<component :is="icon" size="24" class="mr-2 text-primary" />
<h3 class="text-lg font-medium text-gray-800">{{ title }}</h3>
<h3 class="text-lg font-medium">{{ title }}</h3>
</div>
<p class="text-sm text-gray-600">{{ subTitle }}</p>
<p class="text-sm text-gray-600 dark:text-gray-400">{{ subTitle }}</p>
</div>
</template>

View File

@@ -12,8 +12,8 @@
<script setup>
import { computed } from 'vue'
import { Separator } from '@/components/ui/separator'
import { SidebarTrigger } from '@/components/ui/sidebar'
import { Separator } from '@shared-ui/components/ui/separator'
import { SidebarTrigger } from '@shared-ui/components/ui/sidebar'
import { useRoute } from 'vue-router'
const route = useRoute()

View File

@@ -4,9 +4,9 @@ import {
reportsNavItems,
accountNavItems,
contactNavItems
} from '@/constants/navigation'
} from '../../constants/navigation'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@shared-ui/components/ui/collapsible'
import {
Sidebar,
SidebarContent,
@@ -21,8 +21,8 @@ import {
SidebarMenuSubItem,
SidebarProvider,
SidebarRail
} from '@/components/ui/sidebar'
import { useAppSettingsStore } from '@/stores/appSettings'
} from '@shared-ui/components/ui/sidebar'
import { useAppSettingsStore } from '../../stores/appSettings'
import {
ChevronRight,
EllipsisVertical,
@@ -37,13 +37,23 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
} from '@shared-ui/components/ui/dropdown-menu'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@shared-ui/components/ui/alert-dialog'
import { filterNavItems } from '@/utils/nav-permissions'
import { useStorage } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useUserStore } from '@/stores/user'
import { useConversationStore } from '@/stores/conversation'
import { useUserStore } from '../../stores/user'
import { useConversationStore } from '../../stores/conversation'
defineProps({
userTeams: { type: Array, default: () => [] },
@@ -73,8 +83,17 @@ const editView = (view) => {
emit('editView', view)
}
const deleteView = (view) => {
emit('deleteView', view)
const openDeleteConfirmation = (view) => {
viewToDelete.value = view
isDeleteOpen.value = true
}
const handleDeleteView = () => {
if (viewToDelete.value) {
emit('deleteView', viewToDelete.value)
isDeleteOpen.value = false
viewToDelete.value = null
}
}
// Navigation methods with conversation retention
@@ -157,6 +176,13 @@ watch(
const sidebarOpen = useStorage('mainSidebarOpen', true)
const teamInboxOpen = useStorage('teamInboxOpen', true)
const viewInboxOpen = useStorage('viewInboxOpen', true)
// Track which view is being hovered for ellipsis menu visibility
const hoveredViewId = ref(null)
// Track delete confirmation dialog state
const isDeleteOpen = ref(false)
const viewToDelete = ref(null)
</script>
<template>
@@ -472,24 +498,35 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
<CollapsibleContent>
<SidebarMenuSub v-for="view in userViews" :key="view.id">
<SidebarMenuSubItem>
<SidebarMenuSubItem
@mouseenter="hoveredViewId = view.id"
@mouseleave="hoveredViewId = null"
>
<SidebarMenuButton
size="sm"
:isActive="route.params.viewID == view.id"
asChild
>
<a href="#" @click.prevent="navigateToViewInbox(view.id)">
<span class="break-words w-32 truncate">{{ view.name }}</span>
<SidebarMenuAction :showOnHover="true" class="mr-3">
<span class="break-words w-32 truncate" :title="view.name">{{ view.name }}</span>
<SidebarMenuAction
@click.stop
:class="[
'mr-3',
'md:opacity-0',
'data-[state=open]:opacity-100',
{ 'md:opacity-100': hoveredViewId === view.id }
]"
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<DropdownMenuTrigger asChild @click.prevent>
<EllipsisVertical />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="() => editView(view)">
<span>{{ t('globals.messages.edit') }}</span>
</DropdownMenuItem>
<DropdownMenuItem @click="() => deleteView(view)">
<DropdownMenuItem @click="() => openDeleteConfirmation(view)">
<span>{{ t('globals.messages.delete') }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
@@ -513,4 +550,22 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
<slot></slot>
</SidebarInset>
</SidebarProvider>
<!-- View Delete Confirmation Dialog -->
<AlertDialog v-model:open="isDeleteOpen">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{{ t('globals.messages.areYouAbsolutelySure') }}</AlertDialogTitle>
<AlertDialogDescription>
{{ t('globals.messages.deletionConfirmation', { name: t('globals.terms.view') }) }}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{{ t('globals.messages.cancel') }}</AlertDialogCancel>
<AlertDialogAction @click="handleDeleteView">
{{ t('globals.messages.delete') }}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</template>

View File

@@ -118,12 +118,12 @@ import {
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { SidebarMenuButton } from '@/components/ui/sidebar'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Switch } from '@/components/ui/switch'
} from '@shared-ui/components/ui/dropdown-menu'
import { SidebarMenuButton } from '@shared-ui/components/ui/sidebar'
import { Avatar, AvatarFallback, AvatarImage } from '@shared-ui/components/ui/avatar'
import { Switch } from '@shared-ui/components/ui/switch'
import { ChevronsUpDown, CircleUserRound, LogOut, Moon, Sun } from 'lucide-vue-next'
import { useUserStore } from '@/stores/user'
import { useUserStore } from '../../stores/user'
import { useRouter } from 'vue-router'
import { useColorMode } from '@vueuse/core'

View File

@@ -71,8 +71,8 @@
<script setup>
import { Trash2 } from 'lucide-vue-next'
import { defineEmits } from 'vue'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Button } from '@shared-ui/components/ui/button'
import { Skeleton } from '@shared-ui/components/ui/skeleton'
defineProps({
headers: {

View File

@@ -1,6 +1,6 @@
import { computed } from 'vue'
import { useUsersStore } from '@/stores/users'
import { FIELD_TYPE, FIELD_OPERATORS } from '@/constants/filterConfig'
import { useUsersStore } from '../stores/users'
import { FIELD_TYPE, FIELD_OPERATORS } from '../constants/filterConfig'
import { useI18n } from 'vue-i18n'
export function useActivityLogFilters () {

View File

@@ -5,6 +5,7 @@ import { useUsersStore } from '@/stores/users'
import { useTeamStore } from '@/stores/team'
import { useSlaStore } from '@/stores/sla'
import { useCustomAttributeStore } from '@/stores/customAttributes'
import { useTagStore } from '@/stores/tag'
import { FIELD_TYPE, FIELD_OPERATORS } from '@/constants/filterConfig'
import { useI18n } from 'vue-i18n'
@@ -15,6 +16,7 @@ export function useConversationFilters () {
const tStore = useTeamStore()
const slaStore = useSlaStore()
const customAttributeStore = useCustomAttributeStore()
const tagStore = useTagStore()
const { t } = useI18n()
const customAttributeDataTypeToFieldType = {
@@ -69,6 +71,12 @@ export function useConversationFilters () {
type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT,
options: iStore.options
},
tags: {
label: t('globals.terms.tag', 2),
type: FIELD_TYPE.MULTI_SELECT,
operators: FIELD_OPERATORS.MULTI_SELECT,
options: tagStore.tagOptions
}
}))

View File

@@ -1,8 +1,8 @@
import { ref, readonly } from 'vue'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { handleHTTPError } from '@/utils/http'
import api from '@/api'
import { useEmitter } from './useEmitter'
import { EMITTER_EVENTS } from '../constants/emitterEvents.js'
import { handleHTTPError } from '../utils/http'
import api from '../api'
/**
* Composable for handling file uploads

View File

@@ -1,6 +1,6 @@
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import { useUserStore } from '@/stores/user'
import { debounce } from '@/utils/debounce'
import { useUserStore } from '../stores/user'
import { debounce } from '../utils/debounce'
import { useStorage } from '@vueuse/core'
export function useIdleDetection () {

View File

@@ -1,5 +1,5 @@
import { ref, onMounted, onUnmounted } from 'vue'
import { calculateSla } from '@/utils/sla'
import { calculateSla } from '../utils/sla'
export function useSla (dueAt, actualAt) {
const sla = ref(null)

View File

@@ -1,6 +1,7 @@
export const FIELD_TYPE = {
SELECT: 'select',
TAG: 'tag',
MULTI_SELECT: 'multi-select',
TEXT: 'text',
NUMBER: 'number',
RICHTEXT: 'richtext',
@@ -39,4 +40,5 @@ export const FIELD_OPERATORS = {
OPERATOR.LESS_THAN
],
NUMBER: [OPERATOR.EQUALS, OPERATOR.NOT_EQUALS, OPERATOR.GREATER_THAN, OPERATOR.LESS_THAN],
MULTI_SELECT: [OPERATOR.CONTAINS, OPERATOR.NOT_CONTAINS, OPERATOR.SET, OPERATOR.NOT_SET]
}

View File

@@ -12,6 +12,7 @@ export const permissions = {
CONVERSATIONS_UPDATE_TAGS: 'conversations:update_tags',
MESSAGES_READ: 'messages:read',
MESSAGES_WRITE: 'messages:write',
MESSAGES_WRITE_AS_CONTACT: 'messages:write_as_contact',
VIEW_MANAGE: 'view:manage',
GENERAL_SETTINGS_MANAGE: 'general_settings:manage',
NOTIFICATION_SETTINGS_MANAGE: 'notification_settings:manage',

View File

@@ -0,0 +1,3 @@
export const Roles = ["Admin", "Agent"]
export const UserTypeAgent = "agent"
export const UserTypeContact = "contact"

View File

@@ -0,0 +1,13 @@
export const WS_EVENT = {
NEW_MESSAGE: 'new_message',
MESSAGE_PROP_UPDATE: 'message_prop_update',
CONVERSATION_PROP_UPDATE: 'conversation_prop_update',
CONVERSATION_SUBSCRIBE: 'conversation_subscribe',
CONVERSATION_SUBSCRIBED: 'conversation_subscribed',
TYPING: 'typing',
}
// Message types that should not be queued because they become stale quickly
export const WS_EPHEMERAL_TYPES = [
WS_EVENT.TYPING,
]

View File

@@ -148,7 +148,7 @@
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import SimpleTable from '@/components/table/SimpleTable.vue'
import SimpleTable from '@main/components/table/SimpleTable.vue'
import {
Pagination,
PaginationEllipsis,
@@ -158,23 +158,23 @@ import {
PaginationListItem,
PaginationNext,
PaginationPrev
} from '@/components/ui/pagination'
} from '@shared-ui/components/ui/pagination'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import FilterBuilder from '@/components/filter/FilterBuilder.vue'
import { Button } from '@/components/ui/button'
} from '@shared-ui/components/ui/select'
import FilterBuilder from '@main/components/filter/FilterBuilder.vue'
import { Button } from '@shared-ui/components/ui/button'
import { ListFilter, ArrowDownWideNarrow } from 'lucide-vue-next'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { useActivityLogFilters } from '@/composables/useActivityLogFilters'
import { Popover, PopoverContent, PopoverTrigger } from '@shared-ui/components/ui/popover'
import { useActivityLogFilters } from '../../../composables/useActivityLogFilters'
import { useI18n } from 'vue-i18n'
import { format } from 'date-fns'
import { getVisiblePages } from '@/utils/pagination'
import api from '@/api'
import { getVisiblePages } from '../../../utils/pagination'
import api from '../../../api'
const activityLogs = ref([])
const { t } = useI18n()

View File

@@ -304,17 +304,17 @@
<script setup>
import { watch, onMounted, ref, computed } from 'vue'
import { Button } from '@/components/ui/button'
import { Button } from '@shared-ui/components/ui/button/index.js'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { createFormSchema } from './formSchema.js'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import { Checkbox } from '@shared-ui/components/ui/checkbox/index.js'
import { Label } from '@shared-ui/components/ui/label/index.js'
import { vAutoAnimate } from '@formkit/auto-animate/vue'
import { Badge } from '@/components/ui/badge'
import { Badge } from '@shared-ui/components/ui/badge/index.js'
import { Clock, LogIn, Key, RotateCcw, Trash2, Plus, Copy, AlertTriangle } from 'lucide-vue-next'
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@shared-ui/components/ui/form/index.js'
import { Avatar, AvatarFallback, AvatarImage } from '@shared-ui/components/ui/avatar/index.js'
import {
Select,
SelectContent,
@@ -322,9 +322,9 @@ import {
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { SelectTag } from '@/components/ui/select'
import { Input } from '@/components/ui/input'
} from '@shared-ui/components/ui/select/index.js'
import { SelectTag } from '@shared-ui/components/ui/select/index.js'
import { Input } from '@shared-ui/components/ui/input/index.js'
import {
Dialog,
DialogContent,
@@ -332,13 +332,13 @@ import {
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
} from '@shared-ui/components/ui/dialog/index.js'
import { Alert, AlertDescription, AlertTitle } from '@shared-ui/components/ui/alert/index.js'
import { useI18n } from 'vue-i18n'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useEmitter } from '../../../composables/useEmitter.js'
import { EMITTER_EVENTS } from '../../../constants/emitterEvents.js'
import { format } from 'date-fns'
import api from '@/api'
import api from '../../../api/index.js'
const props = defineProps({
initialValues: {
@@ -418,7 +418,6 @@ const onSubmit = form.handleSubmit((values) => {
if (values.availability_status === 'active_group') {
values.availability_status = 'online'
}
values.teams = values.teams.map((team) => ({ name: team }))
props.submitForm(values)
})

View File

@@ -9,7 +9,7 @@ export const createColumns = (t) => [
return h('div', { class: 'text-center' }, t('globals.terms.firstName'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('first_name'))
return h('div', { class: 'text-center' }, row.getValue('first_name'))
}
},
{
@@ -18,7 +18,7 @@ export const createColumns = (t) => [
return h('div', { class: 'text-center' }, t('globals.terms.lastName'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('last_name'))
return h('div', { class: 'text-center' }, row.getValue('last_name'))
}
},
{
@@ -27,7 +27,7 @@ export const createColumns = (t) => [
return h('div', { class: 'text-center' }, t('globals.terms.enabled'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('enabled') ? t('globals.messages.yes') : t('globals.messages.no'))
return h('div', { class: 'text-center' }, row.getValue('enabled') ? t('globals.messages.yes') : t('globals.messages.no'))
}
},
{
@@ -36,7 +36,7 @@ export const createColumns = (t) => [
return h('div', { class: 'text-center' }, t('globals.terms.email'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('email'))
return h('div', { class: 'text-center' }, row.getValue('email'))
}
},
{
@@ -47,7 +47,7 @@ export const createColumns = (t) => [
cell: function ({ row }) {
return h(
'div',
{ class: 'text-center font-medium' },
{ class: 'text-center' },
format(row.getValue('created_at'), 'PPpp')
)
}
@@ -60,7 +60,7 @@ export const createColumns = (t) => [
cell: function ({ row }) {
return h(
'div',
{ class: 'text-center font-medium' },
{ class: 'text-center' },
format(row.getValue('updated_at'), 'PPpp')
)
}

View File

@@ -40,7 +40,7 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
} from '@shared-ui/components/ui/dropdown-menu'
import {
AlertDialog,
AlertDialogAction,
@@ -50,13 +50,13 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
} from '@shared-ui/components/ui/alert-dialog'
import { Button } from '@shared-ui/components/ui/button'
import { useRouter } from 'vue-router'
import { useEmitter } from '@/composables/useEmitter'
import { handleHTTPError } from '@/utils/http'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import api from '@/api'
import { useEmitter } from '../../../composables/useEmitter'
import { handleHTTPError } from '../../../utils/http'
import { EMITTER_EVENTS } from '../../../constants/emitterEvents.js'
import api from '../../../api'
const alertOpen = ref(false)
const emit = useEmitter()

View File

@@ -87,9 +87,9 @@
<script setup>
import { toRefs } from 'vue'
import { Button } from '@/components/ui/button'
import CloseButton from '@/components/button/CloseButton.vue'
import { useTagStore } from '@/stores/tag'
import { Button } from '@shared-ui/components/ui/button'
import CloseButton from '@main/components/button/CloseButton.vue'
import { useTagStore } from '../../../stores/tag'
import {
Select,
SelectContent,
@@ -97,13 +97,13 @@ import {
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { SelectTag } from '@/components/ui/select'
import { useConversationFilters } from '@/composables/useConversationFilters'
import { getTextFromHTML } from '@/utils/strings.js'
} from '@shared-ui/components/ui/select'
import { SelectTag } from '@shared-ui/components/ui/select'
import { useConversationFilters } from '../../../composables/useConversationFilters'
import { getTextFromHTML } from '../../../utils/strings.js'
import { useI18n } from 'vue-i18n'
import Editor from '@/components/editor/TextEditor.vue'
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
import Editor from '@main/components/editor/TextEditor.vue'
import SelectComboBox from '@main/components/combobox/SelectCombobox.vue'
const props = defineProps({
actions: {

View File

@@ -34,7 +34,7 @@
</template>
<script setup>
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@shared-ui/components/ui/tabs'
import { useI18n } from 'vue-i18n'
import RuleTab from './RuleTab.vue'

View File

@@ -190,10 +190,10 @@
<script setup>
import { toRefs, computed, watch } from 'vue'
import { Checkbox } from '@/components/ui/checkbox'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Button } from '@/components/ui/button'
import CloseButton from '@/components/button/CloseButton.vue'
import { Checkbox } from '@shared-ui/components/ui/checkbox'
import { RadioGroup, RadioGroupItem } from '@shared-ui/components/ui/radio-group'
import { Button } from '@shared-ui/components/ui/button'
import CloseButton from '@main/components/button/CloseButton.vue'
import {
Select,
SelectContent,
@@ -202,19 +202,19 @@ import {
SelectLabel,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
} from '@shared-ui/components/ui/select'
import {
TagsInput,
TagsInputInput,
TagsInputItem,
TagsInputItemDelete,
TagsInputItemText
} from '@/components/ui/tags-input'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
} from '@shared-ui/components/ui/tags-input'
import { Label } from '@shared-ui/components/ui/label'
import { Input } from '@shared-ui/components/ui/input'
import { useI18n } from 'vue-i18n'
import { useConversationFilters } from '@/composables/useConversationFilters'
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
import { useConversationFilters } from '../../../composables/useConversationFilters'
import SelectComboBox from '@main/components/combobox/SelectCombobox.vue'
const props = defineProps({
ruleGroup: {

View File

@@ -68,7 +68,7 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
} from '@shared-ui/components/ui/dropdown-menu'
import {
AlertDialog,
AlertDialogAction,
@@ -78,10 +78,10 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@/components/ui/alert-dialog'
} from '@shared-ui/components/ui/alert-dialog'
import { EllipsisVertical } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import { Badge } from '@/components/ui/badge'
import { Badge } from '@shared-ui/components/ui/badge'
const router = useRouter()
const alertOpen = ref(false)

View File

@@ -64,17 +64,17 @@
<script setup>
import { ref, onMounted, watch } from 'vue'
import RuleList from './RuleList.vue'
import { Spinner } from '@/components/ui/spinner'
import { Spinner } from '@shared-ui/components/ui/spinner'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
} from '@shared-ui/components/ui/select'
import { Settings } from 'lucide-vue-next'
import draggable from 'vuedraggable'
import api from '@/api'
import api from '../../../api'
const isLoading = ref(false)
const rules = ref([])

View File

@@ -167,23 +167,23 @@
<script setup>
import { ref, watch, reactive, computed } from 'vue'
import { Button } from '@/components/ui/button'
import { Button } from '@shared-ui/components/ui/button/index.js'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { createFormSchema } from './formSchema.js'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { Calendar } from '@/components/ui/calendar'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
import { Checkbox } from '@shared-ui/components/ui/checkbox/index.js'
import { Label } from '@shared-ui/components/ui/label/index.js'
import { RadioGroup, RadioGroupItem } from '@shared-ui/components/ui/radio-group/index.js'
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@shared-ui/components/ui/form/index.js'
import { Calendar } from '@shared-ui/components/ui/calendar/index.js'
import { Input } from '@shared-ui/components/ui/input/index.js'
import { Popover, PopoverContent, PopoverTrigger } from '@shared-ui/components/ui/popover/index.js'
import { cn } from '@shared-ui/lib/utils.js'
import { format } from 'date-fns'
import { WEEKDAYS } from '@/constants/date'
import { WEEKDAYS } from '../../../constants/date.js'
import { Calendar as CalendarIcon } from 'lucide-vue-next'
import { useI18n } from 'vue-i18n'
import SimpleTable from '@/components/table/SimpleTable.vue'
import SimpleTable from '@main/components/table/SimpleTable.vue'
import {
Dialog,
DialogContent,
@@ -192,7 +192,7 @@ import {
DialogHeader,
DialogTitle,
DialogTrigger
} from '@/components/ui/dialog'
} from '@shared-ui/components/ui/dialog/index.js'
const props = defineProps({
initialValues: {

View File

@@ -9,7 +9,7 @@ export const createColumns = (t) => [
return h('div', { class: 'text-center' }, t('globals.terms.name'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
return h('div', { class: 'text-center' }, row.getValue('name'))
}
},
{
@@ -18,7 +18,7 @@ export const createColumns = (t) => [
return h('div', { class: 'text-center' }, t('globals.terms.createdAt'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, format(row.getValue('created_at'), 'PPpp'))
return h('div', { class: 'text-center' }, format(row.getValue('created_at'), 'PPpp'))
}
},
{
@@ -27,7 +27,7 @@ export const createColumns = (t) => [
return h('div', { class: 'text-center' }, t('globals.terms.updatedAt'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, format(row.getValue('updated_at'), 'PPpp'))
return h('div', { class: 'text-center' }, format(row.getValue('updated_at'), 'PPpp'))
}
},
{

View File

@@ -50,7 +50,7 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
} from '@shared-ui/components/ui/dropdown-menu'
import {
AlertDialog,
AlertDialogAction,
@@ -60,13 +60,13 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
} from '@shared-ui/components/ui/alert-dialog'
import { Button } from '@shared-ui/components/ui/button'
import { useRouter } from 'vue-router'
import api from '@/api'
import { useEmitter } from '@/composables/useEmitter'
import api from '../../../api'
import { useEmitter } from '../../../composables/useEmitter'
import { useI18n } from 'vue-i18n'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { EMITTER_EVENTS } from '../../../constants/emitterEvents.js'
const { t } = useI18n()
const router = useRouter()

View File

@@ -150,14 +150,14 @@ import {
FormItem,
FormLabel,
FormMessage
} from '@/components/ui/form'
} from '@shared-ui/components/ui/form'
import {
TagsInput,
TagsInputInput,
TagsInputItem,
TagsInputItemDelete,
TagsInputItemText
} from '@/components/ui/tags-input'
} from '@shared-ui/components/ui/tags-input'
import {
Select,
SelectContent,
@@ -165,8 +165,8 @@ import {
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { Input } from '@/components/ui/input'
} from '@shared-ui/components/ui/select'
import { Input } from '@shared-ui/components/ui/input'
const props = defineProps({
form: {

View File

@@ -9,7 +9,7 @@ export const createColumns = (t) => [
return h('div', { class: 'text-center' }, t('globals.terms.name'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
return h('div', { class: 'text-center' }, row.getValue('name'))
}
},
{
@@ -18,7 +18,7 @@ export const createColumns = (t) => [
return h('div', { class: 'text-center' }, t('globals.terms.key'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('key'))
return h('div', { class: 'text-center' }, row.getValue('key'))
}
},
{
@@ -27,7 +27,7 @@ export const createColumns = (t) => [
return h('div', { class: 'text-center' }, t('globals.terms.type'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('data_type'))
return h('div', { class: 'text-center' }, row.getValue('data_type'))
}
},
{
@@ -36,7 +36,7 @@ export const createColumns = (t) => [
return h('div', { class: 'text-center' }, t('globals.terms.appliesTo'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('applies_to'))
return h('div', { class: 'text-center' }, row.getValue('applies_to'))
}
},
{
@@ -47,7 +47,7 @@ export const createColumns = (t) => [
cell: function ({ row }) {
return h(
'div',
{ class: 'text-center font-medium' },
{ class: 'text-center' },
format(row.getValue('created_at'), 'PPpp')
)
}
@@ -60,7 +60,7 @@ export const createColumns = (t) => [
cell: function ({ row }) {
return h(
'div',
{ class: 'text-center font-medium' },
{ class: 'text-center' },
format(row.getValue('updated_at'), 'PPpp')
)
}

View File

@@ -44,7 +44,7 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
} from '@shared-ui/components/ui/dropdown-menu'
import {
AlertDialog,
AlertDialogAction,
@@ -54,12 +54,12 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { useEmitter } from '@/composables/useEmitter'
import { handleHTTPError } from '@/utils/http'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import api from '@/api'
} from '@shared-ui/components/ui/alert-dialog'
import { Button } from '@shared-ui/components/ui/button'
import { useEmitter } from '../../../composables/useEmitter'
import { handleHTTPError } from '../../../utils/http'
import { EMITTER_EVENTS } from '../../../constants/emitterEvents.js'
import api from '../../../api'
const alertOpen = ref(false)
const emit = useEmitter()

View File

@@ -171,7 +171,7 @@
<script setup>
import { watch, ref, onMounted } from 'vue'
import { Button } from '@/components/ui/button'
import { Button } from '@shared-ui/components/ui/button/index.js'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { createFormSchema } from './formSchema.js'
@@ -182,7 +182,7 @@ import {
FormLabel,
FormMessage,
FormDescription
} from '@/components/ui/form'
} from '@shared-ui/components/ui/form/index.js'
import {
Select,
SelectContent,
@@ -190,21 +190,21 @@ import {
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
} from '@shared-ui/components/ui/select/index.js'
import {
TagsInput,
TagsInputInput,
TagsInputItem,
TagsInputItemDelete,
TagsInputItemText
} from '@/components/ui/tags-input'
import { Input } from '@/components/ui/input'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useEmitter } from '@/composables/useEmitter'
import { handleHTTPError } from '@/utils/http'
import { timeZones } from '@/constants/timezones.js'
} from '@shared-ui/components/ui/tags-input/index.js'
import { Input } from '@shared-ui/components/ui/input/index.js'
import { EMITTER_EVENTS } from '../../../constants/emitterEvents.js'
import { useEmitter } from '../../../composables/useEmitter.js'
import { handleHTTPError } from '../../../utils/http.js'
import { timeZones } from '../../../constants/timezones.js'
import { useI18n } from 'vue-i18n'
import api from '@/api'
import api from '../../../api/index.js'
const emitter = useEmitter()
const { t } = useI18n()

View File

@@ -360,17 +360,17 @@ import {
FormLabel,
FormMessage,
FormDescription
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Button } from '@/components/ui/button'
} from '@shared-ui/components/ui/form/index.js'
import { Input } from '@shared-ui/components/ui/input/index.js'
import { Switch } from '@shared-ui/components/ui/switch/index.js'
import { Button } from '@shared-ui/components/ui/button/index.js'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
} from '@shared-ui/components/ui/select/index.js'
import { useI18n } from 'vue-i18n'
const props = defineProps({

View File

@@ -48,7 +48,7 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
} from '@shared-ui/components/ui/dropdown-menu'
import {
AlertDialog,
AlertDialogAction,
@@ -58,8 +58,8 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
} from '@shared-ui/components/ui/alert-dialog'
import { Button } from '@shared-ui/components/ui/button'
const alertOpen = ref(false)
const props = defineProps({

View File

@@ -0,0 +1,914 @@
<template>
<form @submit="onSubmit" class="space-y-6 w-full">
<!-- Main Tabs -->
<Tabs v-model="activeTab" class="w-full">
<TabsList class="grid w-full grid-cols-2 sm:grid-cols-3 lg:grid-cols-7 gap-2 h-auto">
<TabsTrigger value="general">{{ $t('admin.inbox.livechat.tabs.general') }}</TabsTrigger>
<TabsTrigger value="appearance">{{
$t('admin.inbox.livechat.tabs.appearance')
}}</TabsTrigger>
<TabsTrigger value="messages">{{ $t('admin.inbox.livechat.tabs.messages') }}</TabsTrigger>
<TabsTrigger value="features">{{ $t('admin.inbox.livechat.tabs.features') }}</TabsTrigger>
<TabsTrigger value="security">{{ $t('admin.inbox.livechat.tabs.security') }}</TabsTrigger>
<TabsTrigger value="prechat">{{ $t('admin.inbox.livechat.tabs.prechat') }}</TabsTrigger>
<TabsTrigger value="users">{{ $t('admin.inbox.livechat.tabs.users') }}</TabsTrigger>
</TabsList>
<div class="mt-8">
<!-- General Tab -->
<div v-show="activeTab === 'general'" class="space-y-6">
<FormField v-slot="{ componentField }" name="name">
<FormItem>
<FormLabel>{{ $t('globals.terms.name') }}</FormLabel>
<FormControl>
<Input type="text" placeholder="" v-bind="componentField" />
</FormControl>
<FormDescription>{{ $t('admin.inbox.name.description') }}</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField, handleChange }" name="enabled">
<FormItem class="flex flex-row items-center justify-between box p-4">
<div class="space-y-0.5">
<FormLabel class="text-base">{{ $t('globals.terms.enabled') }}</FormLabel>
</div>
<FormControl>
<Switch :checked="componentField.modelValue" @update:checked="handleChange" />
</FormControl>
</FormItem>
</FormField>
<FormField v-slot="{ componentField, handleChange }" name="csat_enabled">
<FormItem class="flex flex-row items-center justify-between box p-4">
<div class="space-y-0.5">
<FormLabel class="text-base">{{ $t('admin.inbox.csatSurveys') }}</FormLabel>
<FormDescription>
{{ $t('admin.inbox.csatSurveys.description_1') }}<br />
{{ $t('admin.inbox.csatSurveys.description_2') }}
</FormDescription>
</div>
<FormControl>
<Switch :checked="componentField.modelValue" @update:checked="handleChange" />
</FormControl>
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="config.brand_name">
<FormItem>
<FormLabel>{{
$t('globals.terms.brand') + ' ' + $t('globals.terms.name').toLowerCase()
}}</FormLabel>
<FormControl>
<Input type="text" placeholder="" v-bind="componentField" />
</FormControl>
</FormItem>
</FormField>
<!-- Language -->
<FormField v-slot="{ componentField }" name="config.language">
<FormItem>
<FormLabel>{{ $t('globals.terms.language') }}</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue placeholder="Select language" />
</SelectTrigger>
<SelectContent>
<SelectItem value="en">English</SelectItem>
<SelectItem value="mr">Marathi</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription> </FormDescription>
</FormItem>
</FormField>
<!-- Email Fallback Inbox -->
<FormField v-slot="{ componentField }" name="linked_email_inbox_id">
<FormItem>
<FormLabel>{{ $t('admin.inbox.livechat.conversationContinuity') }}</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue
:placeholder="
$t('globals.messages.select', {
name: $t('globals.terms.inbox').toLowerCase()
})
"
/>
</SelectTrigger>
<SelectContent>
<SelectItem :value="0"> None </SelectItem>
<SelectItem v-for="inbox in emailInboxes" :key="inbox.id" :value="inbox.id">
{{ inbox.name }}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>
{{ $t('admin.inbox.livechat.conversationContinuity.description') }}
</FormDescription>
</FormItem>
</FormField>
</div>
<!-- Appearance Tab -->
<div v-show="activeTab === 'appearance'" class="space-y-6">
<!-- Dark mode -->
<FormField v-slot="{ componentField, handleChange }" name="config.dark_mode">
<FormItem class="flex flex-row items-center justify-between box p-4">
<div class="space-y-0.5">
<FormLabel class="text-base">{{ $t('admin.inbox.livechat.darkMode') }}</FormLabel>
<FormDescription>{{
$t('admin.inbox.livechat.darkMode.description')
}}</FormDescription>
</div>
<FormControl>
<Switch :checked="componentField.modelValue" @update:checked="handleChange" />
</FormControl>
</FormItem>
</FormField>
<!-- Show Powered By -->
<FormField v-slot="{ componentField, handleChange }" name="config.show_powered_by">
<FormItem class="flex flex-row items-center justify-between box p-4">
<div class="space-y-0.5">
<FormLabel class="text-base">{{
$t('admin.inbox.livechat.showPoweredBy')
}}</FormLabel>
<FormDescription>{{
$t('admin.inbox.livechat.showPoweredBy.description')
}}</FormDescription>
</div>
<FormControl>
<Switch :checked="componentField.modelValue" @update:checked="handleChange" />
</FormControl>
</FormItem>
</FormField>
<!-- Logo URL -->
<FormField v-slot="{ componentField }" name="config.logo_url">
<FormItem>
<FormLabel>{{ $t('admin.inbox.livechat.logoUrl') }}</FormLabel>
<FormControl>
<Input
type="url"
placeholder="https://example.com/logo.png"
v-bind="componentField"
/>
</FormControl>
<FormDescription>{{
$t('admin.inbox.livechat.logoUrl.description')
}}</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Colors -->
<div class="space-y-4">
<h4 class="font-medium text-foreground">{{ $t('admin.inbox.livechat.colors') }}</h4>
<div class="grid grid-cols-2 gap-4">
<FormField v-slot="{ componentField }" name="config.colors.primary">
<FormItem>
<FormLabel>{{ $t('admin.inbox.livechat.colors.primary') }}</FormLabel>
<FormControl>
<Input type="color" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
</div>
<!-- Launcher Configuration -->
<div class="space-y-4">
<h4 class="font-medium text-foreground">{{ $t('admin.inbox.livechat.launcher') }}</h4>
<div class="grid grid-cols-2 gap-4">
<!-- Launcher Position -->
<FormField v-slot="{ componentField }" name="config.launcher.position">
<FormItem>
<FormLabel>{{ $t('admin.inbox.livechat.launcher.position') }}</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue placeholder="Select position" />
</SelectTrigger>
<SelectContent>
<SelectItem value="left">{{
$t('admin.inbox.livechat.launcher.position.left')
}}</SelectItem>
<SelectItem value="right">{{
$t('admin.inbox.livechat.launcher.position.right')
}}</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Launcher Logo -->
<FormField v-slot="{ componentField }" name="config.launcher.logo_url">
<FormItem>
<FormLabel>{{ $t('admin.inbox.livechat.launcher.logo') }}</FormLabel>
<FormControl>
<Input
type="url"
placeholder="https://example.com/launcher-logo.png"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
<div class="grid grid-cols-2 gap-4">
<!-- Launcher Spacing Side -->
<FormField v-slot="{ componentField }" name="config.launcher.spacing.side">
<FormItem>
<FormLabel>{{ $t('admin.inbox.livechat.launcher.spacing.side') }}</FormLabel>
<FormControl>
<Input type="number" placeholder="20" v-bind="componentField" />
</FormControl>
<FormDescription>{{
$t('admin.inbox.livechat.launcher.spacing.side.description')
}}</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Launcher Spacing Bottom -->
<FormField v-slot="{ componentField }" name="config.launcher.spacing.bottom">
<FormItem>
<FormLabel>{{ $t('admin.inbox.livechat.launcher.spacing.bottom') }}</FormLabel>
<FormControl>
<Input type="number" placeholder="20" v-bind="componentField" />
</FormControl>
<FormDescription>{{
$t('admin.inbox.livechat.launcher.spacing.bottom.description')
}}</FormDescription>
<FormMessage />
</FormItem>
</FormField>
</div>
</div>
</div>
<!-- Messages Tab -->
<div v-show="activeTab === 'messages'" class="space-y-6">
<FormField v-slot="{ componentField }" name="config.greeting_message">
<FormItem>
<FormLabel>{{ $t('admin.inbox.livechat.greetingMessage') }}</FormLabel>
<FormControl>
<Textarea
v-bind="componentField"
placeholder="Welcome! How can we help you today?"
rows="2"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="config.introduction_message">
<FormItem>
<FormLabel>{{ $t('admin.inbox.livechat.introductionMessage') }}</FormLabel>
<FormControl>
<Textarea v-bind="componentField" placeholder="We're here to help!" rows="2" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="config.chat_introduction">
<FormItem>
<FormLabel>{{ $t('admin.inbox.livechat.chatIntroduction') }}</FormLabel>
<FormControl>
<Textarea
v-bind="componentField"
placeholder="Ask us anything, or share your feedback."
rows="2"
/>
</FormControl>
<FormDescription>{{
$t('admin.inbox.livechat.chatIntroduction.description')
}}</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- External Links -->
<div class="space-y-4">
<h4 class="font-medium text-foreground">
{{ $t('admin.inbox.livechat.externalLinks') }}
</h4>
<FormField name="config.external_links">
<FormItem>
<div class="space-y-3">
<div
v-for="(link, index) in externalLinks"
:key="index"
class="flex items-center gap-2 p-3 border rounded"
>
<div class="flex-1 grid grid-cols-2 gap-2">
<Input
v-model="link.text"
placeholder="Link Text"
@input="updateExternalLinks"
/>
<Input
v-model="link.url"
placeholder="https://example.com"
@input="updateExternalLinks"
/>
</div>
<Button
type="button"
variant="ghost"
size="sm"
@click="removeExternalLink(index)"
>
<X class="w-4 h-4" />
</Button>
</div>
<Button type="button" variant="outline" size="sm" @click="addExternalLink">
<Plus class="w-4 h-4 mr-2" />
{{ $t('admin.inbox.livechat.externalLinks.add') }}
</Button>
</div>
<FormDescription>
{{ $t('admin.inbox.livechat.externalLinks.description') }}
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
</div>
<!-- Notice Banner -->
<div class="space-y-4">
<h4 class="font-medium text-foreground">
{{ $t('admin.inbox.livechat.noticeBanner') }}
</h4>
<FormField
v-slot="{ componentField, handleChange }"
name="config.notice_banner.enabled"
>
<FormItem class="flex flex-row items-center justify-between box p-4">
<div class="space-y-0.5">
<FormLabel class="text-base">{{
$t('admin.inbox.livechat.noticeBanner.enabled')
}}</FormLabel>
</div>
<FormControl>
<Switch :checked="componentField.modelValue" @update:checked="handleChange" />
</FormControl>
</FormItem>
</FormField>
<FormField
v-slot="{ componentField }"
name="config.notice_banner.text"
v-if="form.values.config?.notice_banner?.enabled"
>
<FormItem>
<FormLabel>{{ $t('admin.inbox.livechat.noticeBanner.text') }}</FormLabel>
<FormControl>
<Textarea
v-bind="componentField"
placeholder="Our response times are slower than usual. We're working hard to get to your message."
rows="2"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
</div>
<!-- Features Tab -->
<div v-show="activeTab === 'features'" class="space-y-6">
<!-- Office Hours -->
<div class="space-y-4">
<h4 class="font-medium text-foreground">
{{ $t('admin.inbox.livechat.officeHours') }}
</h4>
<FormField
v-slot="{ componentField, handleChange }"
name="config.show_office_hours_in_chat"
>
<FormItem class="flex flex-row items-center justify-between box p-4">
<div class="space-y-0.5">
<FormLabel class="text-base">{{
$t('admin.inbox.livechat.showOfficeHoursInChat')
}}</FormLabel>
<FormDescription>{{
$t('admin.inbox.livechat.showOfficeHoursInChat.description')
}}</FormDescription>
</div>
<FormControl>
<Switch :checked="componentField.modelValue" @update:checked="handleChange" />
</FormControl>
</FormItem>
</FormField>
<FormField
v-slot="{ componentField, handleChange }"
name="config.show_office_hours_after_assignment"
>
<FormItem class="flex flex-row items-center justify-between box p-4">
<div class="space-y-0.5">
<FormLabel class="text-base">{{
$t('admin.inbox.livechat.showOfficeHoursAfterAssignment')
}}</FormLabel>
<FormDescription>{{
$t('admin.inbox.livechat.showOfficeHoursAfterAssignment.description')
}}</FormDescription>
</div>
<FormControl>
<Switch
:checked="componentField.modelValue"
@update:checked="handleChange"
:disabled="!form.values.config.show_office_hours_in_chat"
/>
</FormControl>
</FormItem>
</FormField>
<FormField
v-if="form.values.config.show_office_hours_in_chat"
v-slot="{ componentField }"
name="config.chat_reply_expectation_message"
>
<FormItem>
<FormLabel>{{ $t('admin.inbox.livechat.chatReplyExpectationMessage') }}</FormLabel>
<FormControl>
<Input type="text" v-bind="componentField" />
</FormControl>
<FormDescription>
{{ $t('admin.inbox.livechat.chatReplyExpectationMessage.description') }}
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
</div>
<!-- Chat Features -->
<div class="space-y-4">
<h4 class="font-medium text-foreground">{{ $t('admin.inbox.livechat.features') }}</h4>
<div class="space-y-3">
<FormField
v-slot="{ componentField, handleChange }"
name="config.features.file_upload"
>
<FormItem class="flex flex-row items-center justify-between box p-4">
<div class="space-y-0.5">
<FormLabel class="text-base">{{
$t('admin.inbox.livechat.features.fileUpload')
}}</FormLabel>
<FormDescription>{{
$t('admin.inbox.livechat.features.fileUpload.description')
}}</FormDescription>
</div>
<FormControl>
<Switch :checked="componentField.modelValue" @update:checked="handleChange" />
</FormControl>
</FormItem>
</FormField>
<FormField v-slot="{ componentField, handleChange }" name="config.features.emoji">
<FormItem class="flex flex-row items-center justify-between box p-4">
<div class="space-y-0.5">
<FormLabel class="text-base">{{
$t('admin.inbox.livechat.features.emoji')
}}</FormLabel>
<FormDescription>{{
$t('admin.inbox.livechat.features.emoji.description')
}}</FormDescription>
</div>
<FormControl>
<Switch :checked="componentField.modelValue" @update:checked="handleChange" />
</FormControl>
</FormItem>
</FormField>
</div>
</div>
</div>
<!-- Security Tab -->
<div v-show="activeTab === 'security'" class="space-y-6">
<!-- Secret Key (readonly) -->
<FormField v-slot="{ componentField }" name="secret">
<FormItem>
<FormLabel>{{ $t('admin.inbox.livechat.secretKey') }}</FormLabel>
<FormControl>
<Input type="password" v-bind="componentField" />
</FormControl>
<FormDescription>{{
$t('admin.inbox.livechat.secretKey.description')
}}</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Trusted Domains -->
<div class="space-y-4">
<h4 class="font-medium text-foreground">
{{ $t('admin.inbox.livechat.trustedDomains') }}
</h4>
<FormField v-slot="{ componentField }" name="config.trusted_domains">
<FormItem>
<FormLabel>{{ $t('admin.inbox.livechat.trustedDomains.list') }}</FormLabel>
<FormControl>
<Textarea
v-bind="componentField"
placeholder="example.com&#10;subdomain.example.com&#10;another-domain.com"
rows="4"
/>
</FormControl>
<FormDescription>{{
$t('admin.inbox.livechat.trustedDomains.description')
}}</FormDescription>
<FormMessage />
</FormItem>
</FormField>
</div>
</div>
<!-- Pre-Chat Form Tab -->
<div v-show="activeTab === 'prechat'" class="space-y-6">
<PreChatFormConfig v-model="prechatConfig" />
</div>
<!-- Users Tab -->
<div v-show="activeTab === 'users'" class="space-y-6">
<Tabs :model-value="selectedUserTab" @update:model-value="selectedUserTab = $event">
<TabsList class="grid w-full grid-cols-2">
<TabsTrigger value="visitors">
{{ $t('admin.inbox.livechat.userSettings.visitors') }}
</TabsTrigger>
<TabsTrigger value="users">
{{ $t('admin.inbox.livechat.userSettings.users') }}
</TabsTrigger>
</TabsList>
<div class="space-y-4 mt-4">
<!-- Visitors Settings -->
<div v-show="selectedUserTab === 'visitors'" class="space-y-4">
<FormField
v-slot="{ componentField }"
name="config.visitors.start_conversation_button_text"
>
<FormItem>
<FormLabel>{{
$t('admin.inbox.livechat.startConversationButtonText')
}}</FormLabel>
<FormControl>
<Input v-bind="componentField" placeholder="Start conversation" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField
v-slot="{ componentField, handleChange }"
name="config.visitors.allow_start_conversation"
>
<FormItem class="flex flex-row items-center justify-between box p-4">
<div class="space-y-0.5">
<FormLabel class="text-base">{{
$t('admin.inbox.livechat.allowStartConversation')
}}</FormLabel>
<FormDescription>{{
$t('admin.inbox.livechat.allowStartConversation.visitors.description')
}}</FormDescription>
</div>
<FormControl>
<Switch :checked="componentField.modelValue" @update:checked="handleChange" />
</FormControl>
</FormItem>
</FormField>
<FormField
v-slot="{ componentField, handleChange }"
name="config.visitors.prevent_multiple_conversations"
>
<FormItem class="flex flex-row items-center justify-between box p-4">
<div class="space-y-0.5">
<FormLabel class="text-base">{{
$t('admin.inbox.livechat.preventMultipleConversations')
}}</FormLabel>
<FormDescription>{{
$t('admin.inbox.livechat.preventMultipleConversations.visitors.description')
}}</FormDescription>
</div>
<FormControl>
<Switch :checked="componentField.modelValue" @update:checked="handleChange" />
</FormControl>
</FormItem>
</FormField>
</div>
<!-- Users Settings -->
<div v-show="selectedUserTab === 'users'" class="space-y-4">
<FormField
v-slot="{ componentField }"
name="config.users.start_conversation_button_text"
>
<FormItem>
<FormLabel>{{
$t('admin.inbox.livechat.startConversationButtonText')
}}</FormLabel>
<FormControl>
<Input v-bind="componentField" placeholder="Start conversation" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField
v-slot="{ componentField, handleChange }"
name="config.users.allow_start_conversation"
>
<FormItem class="flex flex-row items-center justify-between box p-4">
<div class="space-y-0.5">
<FormLabel class="text-base">{{
$t('admin.inbox.livechat.allowStartConversation')
}}</FormLabel>
<FormDescription>{{
$t('admin.inbox.livechat.allowStartConversation.users.description')
}}</FormDescription>
</div>
<FormControl>
<Switch :checked="componentField.modelValue" @update:checked="handleChange" />
</FormControl>
</FormItem>
</FormField>
<FormField
v-slot="{ componentField, handleChange }"
name="config.users.prevent_multiple_conversations"
>
<FormItem class="flex flex-row items-center justify-between box p-4">
<div class="space-y-0.5">
<FormLabel class="text-base">{{
$t('admin.inbox.livechat.preventMultipleConversations')
}}</FormLabel>
<FormDescription>{{
$t('admin.inbox.livechat.preventMultipleConversations.users.description')
}}</FormDescription>
</div>
<FormControl>
<Switch :checked="componentField.modelValue" @update:checked="handleChange" />
</FormControl>
</FormItem>
</FormField>
</div>
</div>
</Tabs>
</div>
</div>
</Tabs>
<Button type="submit" :is-loading="isLoading" :disabled="isLoading">
{{ submitLabel }}
</Button>
</form>
</template>
<script setup>
import { watch, computed, ref, onMounted } from 'vue'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { createFormSchema } from './livechatFormSchema.js'
import { useInboxStore } from '@/stores/inbox'
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
FormDescription
} from '@shared-ui/components/ui/form'
import { Input } from '@shared-ui/components/ui/input'
import { Textarea } from '@shared-ui/components/ui/textarea'
import { Switch } from '@shared-ui/components/ui/switch'
import { Button } from '@shared-ui/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@shared-ui/components/ui/select'
import { Tabs, TabsList, TabsTrigger } from '@shared-ui/components/ui/tabs'
import { Plus, X } from 'lucide-vue-next'
import { useI18n } from 'vue-i18n'
import PreChatFormConfig from './PreChatFormConfig.vue'
const props = defineProps({
initialValues: {
type: Object,
default: () => ({})
},
submitForm: {
type: Function,
required: true
},
submitLabel: {
type: String,
default: ''
},
isLoading: {
type: Boolean,
default: false
}
})
const { t } = useI18n()
const activeTab = ref('general')
const selectedUserTab = ref('visitors')
const externalLinks = ref([])
const prechatConfig = ref({})
const inboxStore = useInboxStore()
const emailInboxes = computed(() =>
inboxStore.inboxes.filter((inbox) => inbox.channel === 'email' && inbox.enabled)
)
const form = useForm({
validationSchema: toTypedSchema(createFormSchema(t)),
initialValues: {
name: '',
enabled: true,
secret: '',
csat_enabled: false,
linked_email_inbox_id: null,
config: {
brand_name: '',
dark_mode: false,
show_powered_by: true,
language: 'en',
logo_url: '',
launcher: {
position: 'right',
logo_url: '',
spacing: {
side: 20,
bottom: 20
}
},
greeting_message: '',
introduction_message: '',
chat_introduction: 'Ask us anything, or share your feedback.',
show_office_hours_in_chat: false,
show_office_hours_after_assignment: false,
chat_reply_expectation_message: 'We typically reply in 5 minutes.',
notice_banner: {
enabled: false,
text: 'Our response times are slower than usual. We regret the inconvenience caused.'
},
colors: {
primary: '#2563eb'
},
features: {
file_upload: true,
emoji: true
},
trusted_domains: '',
external_links: [],
visitors: {
start_conversation_button_text: 'Start conversation',
allow_start_conversation: true,
prevent_multiple_conversations: false
},
users: {
start_conversation_button_text: 'Start conversation',
allow_start_conversation: true,
prevent_multiple_conversations: false
},
prechat_form: {
enabled: false,
title: '',
fields: [
{
key: 'name',
type: 'text',
label: 'Full name',
placeholder: 'Enter your name',
required: true,
enabled: true,
order: 1,
is_default: true
},
{
key: 'email',
type: 'email',
label: 'Email address',
placeholder: 'your@email.com',
required: true,
enabled: true,
order: 2,
is_default: true
}
]
}
}
}
})
const submitLabel = computed(() => {
return props.submitLabel || t('globals.messages.save')
})
const addExternalLink = () => {
externalLinks.value.push({ text: '', url: '' })
updateExternalLinks()
}
const removeExternalLink = (index) => {
externalLinks.value.splice(index, 1)
updateExternalLinks()
}
const updateExternalLinks = () => {
form.setFieldValue('config.external_links', externalLinks.value)
}
// Fetch inboxes on mount for the linked email inbox dropdown
onMounted(() => {
inboxStore.fetchInboxes()
})
const onSubmit = form.handleSubmit(async (values) => {
// Transform trusted_domains from textarea to array
if (values.config.trusted_domains) {
values.config.trusted_domains = values.config.trusted_domains
.split('\n')
.map((domain) => domain.trim())
.filter((domain) => domain)
} else {
values.config.trusted_domains = []
}
// Filter out incomplete external links before submission
if (values.config.external_links) {
values.config.external_links = values.config.external_links.filter(
(link) => link.text && link.url
)
}
// if linked email inbox id is 0, nullify the field
if (values.linked_email_inbox_id === 0) {
values.linked_email_inbox_id = null
}
await props.submitForm(values)
})
// Watch for prechat config changes and sync with form
watch(
prechatConfig,
(newConfig) => {
form.setFieldValue('config.prechat_form', newConfig)
},
{ deep: true }
)
watch(
() => props.initialValues,
(newValues) => {
if (Object.keys(newValues).length === 0) {
return
}
// Transform trusted_domains array back to textarea format
if (newValues.config?.trusted_domains && Array.isArray(newValues.config.trusted_domains)) {
newValues.config.trusted_domains = newValues.config.trusted_domains.join('\n')
}
// Set external links for the reactive array
if (newValues.config?.external_links) {
externalLinks.value = [...newValues.config.external_links]
}
// Set prechat config
if (newValues.config?.prechat_form) {
prechatConfig.value = { ...newValues.config.prechat_form }
}
form.setValues(newValues)
},
{ deep: true, immediate: true }
)
</script>

View File

@@ -0,0 +1,278 @@
<template>
<div class="space-y-6">
<!-- Master Toggle -->
<div class="flex flex-row items-center justify-between box p-4">
<div class="space-y-0.5">
<label class="text-base font-medium">
{{ $t('admin.inbox.livechat.prechatForm.enabled') }}
</label>
<p class="text-sm text-muted-foreground">
{{ $t('admin.inbox.livechat.prechatForm.enabled.description') }}
</p>
</div>
<Switch v-model:checked="prechatConfig.enabled" />
</div>
<!-- Form Configuration -->
<div v-if="prechatConfig.enabled" class="space-y-6">
<!-- Form Title -->
<div>
<label class="text-sm font-medium">
{{ $t('admin.inbox.livechat.prechatForm.title') }}
</label>
<Input
type="text"
v-model="prechatConfig.title"
placeholder="Tell us about yourself"
class="mt-1"
/>
<p class="text-sm text-muted-foreground mt-1">
{{ $t('admin.inbox.livechat.prechatForm.title.description') }}
</p>
</div>
<!-- Fields Configuration -->
<div class="space-y-4">
<div class="flex justify-between items-center">
<h4 class="font-medium text-foreground">
{{ $t('admin.inbox.livechat.prechatForm.fields') }}
</h4>
<Button
variant="outline"
size="sm"
@click="fetchCustomAttributes"
:disabled="availableCustomAttributes.length === 0"
>
<Plus class="w-4 h-4 mr-2" />
{{ $t('admin.inbox.livechat.prechatForm.addField') }}
</Button>
</div>
<!-- Field List -->
<div class="space-y-3">
<Draggable
v-model="draggableFields"
:item-key="(field) => field.key || `field_${field.custom_attribute_id || 'unknown'}`"
:animation="200"
class="space-y-3"
>
<template #item="{ element: field, index }">
<div :key="field.key || `field-${index}`" class="border rounded-lg p-4 space-y-4">
<!-- Field Header -->
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<div class="cursor-move text-muted-foreground">
<GripVertical class="w-4 h-4" />
</div>
<div>
<div class="font-medium">{{ field.label }}</div>
<div class="text-sm text-muted-foreground">
{{ field.type }} {{ field.is_default ? '(Default)' : '(Custom)' }}
</div>
</div>
</div>
<div class="flex items-center space-x-2">
<Switch v-model:checked="field.enabled" />
<Button
v-if="!field.is_default"
variant="ghost"
size="sm"
@click="removeField(index)"
>
<X class="w-4 h-4" />
</Button>
</div>
</div>
<!-- Field Configuration -->
<div v-if="field.enabled" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<!-- Label -->
<div>
<label class="text-sm font-medium">{{ $t('globals.terms.label') }}</label>
<Input v-model="field.label" placeholder="Field label" class="mt-1" />
</div>
<!-- Placeholder -->
<div>
<label class="text-sm font-medium">
{{ $t('globals.terms.placeholder') }}
</label>
<Input
v-model="field.placeholder"
placeholder="Field placeholder"
class="mt-1"
/>
</div>
</div>
<!-- Required -->
<div class="flex items-center space-x-2">
<Checkbox v-model:checked="field.required" />
<label class="text-sm">{{ $t('globals.terms.required') }}</label>
</div>
</div>
</div>
</template>
</Draggable>
<!-- Empty State -->
<div v-if="formFields.length === 0" class="text-center py-8 text-muted-foreground">
{{ $t('admin.inbox.livechat.prechatForm.noFields') }}
</div>
</div>
<!-- Custom Attributes Selection -->
<div v-if="availableCustomAttributes.length > 0" class="space-y-3">
<h5 class="font-medium text-sm">
{{ $t('admin.inbox.livechat.prechatForm.availableFields') }}
</h5>
<div class="grid grid-cols-2 gap-2 max-h-48 overflow-y-auto">
<div
v-for="attr in availableCustomAttributes"
:key="attr.id"
class="flex items-center space-x-2 p-2 border rounded cursor-pointer hover:bg-accent"
@click="addCustomAttributeToForm(attr)"
>
<div class="flex-1">
<div class="font-medium text-sm">{{ attr.name }}</div>
<div class="text-xs text-muted-foreground">{{ attr.data_type }}</div>
</div>
<Plus class="w-4 h-4 text-muted-foreground" />
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { Input } from '@shared-ui/components/ui/input'
import { Button } from '@shared-ui/components/ui/button'
import { Switch } from '@shared-ui/components/ui/switch'
import { Checkbox } from '@shared-ui/components/ui/checkbox'
import { Plus, X, GripVertical } from 'lucide-vue-next'
import Draggable from 'vuedraggable'
import api from '@/api'
const prechatConfig = defineModel({
default: () => ({
enabled: false,
title: '',
fields: [
{
key: 'name',
type: 'text',
label: 'Full name',
placeholder: 'Enter your name',
required: true,
enabled: true,
order: 1,
is_default: true
},
{
key: 'email',
type: 'email',
label: 'Email address',
placeholder: 'your@email.com',
required: true,
enabled: true,
order: 2,
is_default: true
}
]
})
})
const customAttributes = ref([])
const formFields = computed(() => {
return prechatConfig.value.fields || []
})
const availableCustomAttributes = computed(() => {
const usedIds = formFields.value
.filter((field) => field.custom_attribute_id)
.map((field) => field.custom_attribute_id)
return customAttributes.value.filter((attr) => !usedIds.includes(attr.id))
})
const draggableFields = computed({
get() {
return prechatConfig.value.fields || []
},
set(newValue) {
const fieldsWithUpdatedOrder = newValue.map((field, index) => ({
...field,
order: index + 1
}))
prechatConfig.value.fields = fieldsWithUpdatedOrder
}
})
const removeField = (index) => {
const fields = formFields.value.filter((_, i) => i !== index)
prechatConfig.value.fields = fields
}
const addCustomAttributeToForm = (attribute) => {
const newField = {
key: attribute.key || `custom_attr_${attribute.id || Date.now()}`,
type: attribute.data_type,
label: attribute.name,
placeholder: '',
required: false,
enabled: false,
order: formFields.value.length + 1,
is_default: false,
custom_attribute_id: attribute.id
}
const fields = [...formFields.value, newField]
prechatConfig.value.fields = fields
}
const fetchCustomAttributes = async () => {
try {
// Fetch both contact and conversation custom attributes
const [contactAttrs, conversationAttrs] = await Promise.all([
api.getCustomAttributes('contact'),
api.getCustomAttributes('conversation')
])
customAttributes.value = [
...(contactAttrs.data?.data || []),
...(conversationAttrs.data?.data || [])
]
// Clean up orphaned custom attribute fields
const availableCustomAttrIds = customAttributes.value.map((attr) => attr.id)
const cleanedFields = (prechatConfig.value.fields || []).filter((field) => {
// Keep default fields
if (field.is_default) return true
// Keep custom fields that still exist
if (field.custom_attribute_id && availableCustomAttrIds.includes(field.custom_attribute_id))
return true
// Remove orphaned custom fields
return false
})
// Update fields if any were removed
if (cleanedFields.length !== (prechatConfig.value.fields || []).length) {
prechatConfig.value.fields = cleanedFields
}
} catch (error) {
console.error('Error fetching custom attributes:', error)
customAttributes.value = []
}
}
onMounted(() => {
fetchCustomAttributes()
})
</script>

View File

@@ -1,5 +1,5 @@
import * as z from 'zod'
import { isGoDuration } from '@/utils/strings'
import { isGoDuration } from '../../../utils/strings'
export const createFormSchema = (t) => z.object({
name: z.string().min(1, t('globals.messages.required')),

View File

@@ -0,0 +1,86 @@
import { z } from 'zod'
export const createFormSchema = (t) => z.object({
name: z.string().min(1, { message: t('globals.messages.required') }),
enabled: z.boolean(),
csat_enabled: z.boolean(),
secret: z.string(),
linked_email_inbox_id: z.number().nullable().optional(),
config: z.object({
brand_name: z.string().min(1, { message: t('globals.messages.required') }),
dark_mode: z.boolean(),
show_powered_by: z.boolean(),
language: z.string().min(1, { message: t('globals.messages.required') }),
logo_url: z.string().url({
message: t('globals.messages.invalid', {
name: t('globals.terms.url').toLowerCase()
})
}).optional().or(z.literal('')),
launcher: z.object({
position: z.enum(['left', 'right']),
logo_url: z.string().url({
message: t('globals.messages.invalid', {
name: t('globals.terms.url').toLowerCase()
})
}).optional().or(z.literal('')),
spacing: z.object({
side: z.number().min(0),
bottom: z.number().min(0),
})
}),
greeting_message: z.string().optional(),
introduction_message: z.string().optional(),
chat_introduction: z.string(),
show_office_hours_in_chat: z.boolean(),
show_office_hours_after_assignment: z.boolean(),
notice_banner: z.object({
enabled: z.boolean(),
text: z.string().optional()
}),
colors: z.object({
primary: z.string().regex(/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/, {
message: t('globals.messages.invalid', {
name: t('admin.inbox.livechat.colors').toLowerCase()
})
}),
}),
features: z.object({
file_upload: z.boolean(),
emoji: z.boolean(),
}),
trusted_domains: z.string().optional(),
external_links: z.array(z.object({
text: z.string().min(1),
url: z.string().url({
message: t('globals.messages.invalid', {
name: t('globals.terms.url').toLowerCase()
})
})
})),
visitors: z.object({
start_conversation_button_text: z.string(),
allow_start_conversation: z.boolean(),
prevent_multiple_conversations: z.boolean(),
}),
users: z.object({
start_conversation_button_text: z.string(),
allow_start_conversation: z.boolean(),
prevent_multiple_conversations: z.boolean(),
}),
prechat_form: z.object({
enabled: z.boolean(),
title: z.string().optional(),
fields: z.array(z.object({
key: z.string().min(1),
type: z.enum(['text', 'email', 'number', 'checkbox', 'date', 'link', 'list']),
label: z.string().min(1, { message: t('globals.messages.required') }),
placeholder: z.string().optional(),
required: z.boolean(),
enabled: z.boolean(),
order: z.number().min(1),
is_default: z.boolean(),
custom_attribute_id: z.number().optional()
}))
})
})
})

View File

@@ -129,7 +129,7 @@
</template>
<script setup>
import { Button } from '@/components/ui/button'
import { Button } from '@shared-ui/components/ui/button'
import { Plus } from 'lucide-vue-next'
import {
Select,
@@ -138,11 +138,11 @@ import {
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import CloseButton from '@/components/button/CloseButton.vue'
import { SelectTag } from '@/components/ui/select'
import { useTagStore } from '@/stores/tag'
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
} from '@shared-ui/components/ui/select'
import CloseButton from '@main/components/button/CloseButton.vue'
import { SelectTag } from '@shared-ui/components/ui/select'
import { useTagStore } from '../../../stores/tag'
import SelectComboBox from '@main/components/combobox/SelectCombobox.vue'
const model = defineModel('actions', {
type: Array,

View File

@@ -150,17 +150,17 @@
import { ref, watch, computed } from 'vue'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/spinner'
import { Input } from '@/components/ui/input'
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { Button } from '@shared-ui/components/ui/button/index.js'
import { Spinner } from '@shared-ui/components/ui/spinner/index.js'
import { Input } from '@shared-ui/components/ui/input/index.js'
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@shared-ui/components/ui/form/index.js'
import ActionBuilder from '@/features/admin/macros/ActionBuilder.vue'
import { useConversationFilters } from '@/composables/useConversationFilters'
import { useUsersStore } from '@/stores/users'
import { useTeamStore } from '@/stores/team'
import { getTextFromHTML } from '@/utils/strings.js'
import { useConversationFilters } from '../../../composables/useConversationFilters.js'
import { useUsersStore } from '../../../stores/users.js'
import { useTeamStore } from '../../../stores/team.js'
import { getTextFromHTML } from '../../../utils/strings.js'
import { createFormSchema } from './formSchema.js'
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
import SelectComboBox from '@main/components/combobox/SelectCombobox.vue'
import {
Select,
SelectContent,
@@ -169,9 +169,9 @@ import {
SelectTrigger,
SelectValue,
SelectTag
} from '@/components/ui/select'
} from '@shared-ui/components/ui/select/index.js'
import { useI18n } from 'vue-i18n'
import Editor from '@/components/editor/TextEditor.vue'
import Editor from '@main/components/editor/TextEditor.vue'
const { macroActions } = useConversationFilters()
const { t } = useI18n()

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