Compare commits

...

186 Commits

Author SHA1 Message Date
Abhinav Raut
7217086f3f add instructions for authenticated users for live chat widget 2025-10-05 15:28:03 +05:30
Abhinav Raut
98b3b54b6f Add installation tab to live chat form for copying widget js snippet
New common CopyButton component for copying text to clipboard
2025-10-05 14:25:41 +05:30
Abhinav Raut
aae8d1f793 Consider visitors and contacts same, as the only difference is that visitors are not authenticated.
Show a warning in sidebar when a conversation with a visitor is opened.

Move string.js utils to shared-ui utils

Modify user fetch queries to allow optional filter by multiple user types not just one.

Fix capitalizaiton for live chat en translations
2025-10-05 12:28:47 +05:30
Abhinav Raut
7858a9492d add missing error check 2025-10-04 18:30:51 +05:30
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
b971619ea6 use tabs for search results seperation also looks better now. 2025-08-14 16:12:29 +05:30
Abhinav Raut
69accaebef fix: conversations not reopening on reply from contacts (only when there's an attachment in the reply) else the convo would reopen, the conversation_uuid field was empty as it wasn't part of the get-messages query. 2025-08-14 16:11:27 +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
27de73536e Update confirmed-bug.md 2025-07-23 00:18:36 +05:30
Abhinav Raut
df108a3363 feat: add confirmed and possible bug report templates 2025-07-23 00:14:34 +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
266c3dab72 Merge pull request #121 from abhinavxd/dependabot/go_modules/golang.org/x/oauth2-0.27.0
chore(deps): bump golang.org/x/oauth2 from 0.21.0 to 0.27.0
2025-07-20 14:42:29 +05:30
dependabot[bot]
bf2c1fff6f chore(deps): bump golang.org/x/oauth2 from 0.21.0 to 0.27.0
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.21.0 to 0.27.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.21.0...v0.27.0)

---
updated-dependencies:
- dependency-name: golang.org/x/oauth2
  dependency-version: 0.27.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-20 09:11:03 +00:00
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
2930af0c4f feat: add API getting started guide and update navigation 2025-07-07 01:06:28 +05:30
Abhinav Raut
389c4e3dd3 fix: allow configurable webhook request timeout from config.toml 2025-07-07 00:26:31 +05:30
Abhinav Raut
9a119e6dc3 change log level from Warn to Info for zero rules 2025-07-07 00:18:33 +05:30
Abhinav Raut
ee178d383d fix: remove hardcoded color for weekday in business hrs form
- change holiday form action label to `add`
2025-07-07 00:00:30 +05:30
Abhinav Raut
fc4db676d9 fix: correct capitalization for "Business hour" in English translation 2025-07-06 23:59:47 +05:30
Abhinav Raut
70cb3d0f80 fix: make code mirror editor fill remaining space 2025-07-06 23:59:35 +05:30
Abhinav Raut
c9920c3377 fix: set chunkSizeWarningLimit to 600 kb in build config as code mirror's ~550 kb 2025-07-06 21:44:26 +05:30
Abhinav Raut
6d62c3a4ba fix: update code mirror dark mode detection to use VueUse's useColorMode 2025-07-06 21:10:09 +05:30
Abhinav Raut
d9b5fb8f0f fix: adjust margin for URL display in webhook list data table 2025-07-06 20:39:57 +05:30
Abhinav Raut
3de320f1fb fix: correct capitalization in english translation 2025-07-06 20:34:06 +05:30
Abhinav Raut
be977dcff2 feat: show date and month below each message bubble
Use `created_at` timestamp instead of `updated_at` timestamp in message bubble.

Fixes #117
2025-07-06 20:14:18 +05:30
Abhinav Raut
5e19f13e18 fix: make vue-letter break all words for contact messages 2025-07-06 19:52:46 +05:30
Abhinav Raut
ccc5940dd9 return created message in message fetch API 2025-07-06 19:51:44 +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
Abhinav Raut
4203b82e90 Update README.md 2025-06-28 23:34:13 +05:30
Abhinav Raut
ba07e224c2 Update README.md 2025-06-21 22:11:44 +05:30
Abhinav Raut
3fff65150f Merge pull request #109 from ketan-10/migrate-codeflask-to-codemirror
Migrate codeflask to codemirror
2025-06-21 17:55:18 +05:30
ketan
c4fcf6bd91 feat: integrate CodeMirror for code editing and update styles. 2025-06-21 16:28:34 +05:30
Abhinav Raut
5ea1b9e84c fix: retain conversation view when converstion list type is changed 2025-06-21 14:44:36 +05:30
Abhinav Raut
5b522888bc Merge pull request #108 from abhinavxd/fix/post-put-handlers-return-objects
fix: Return created/updated objects in POST/PUT responses
2025-06-21 11:31:45 +05:30
Abhinav Raut
dc2250ce50 remove console log 2025-06-21 11:27:53 +05:30
Abhinav Raut
839a06f0d2 fixes to business hrs form 2025-06-20 19:35:09 +05:30
Abhinav Raut
d2e5d85e3a fix: return created/updated objects in POST/PUT responses with masked secrets
All POST/PUT handlers now return actual database objects instead of `true`
2025-06-20 19:35:09 +05:30
Abhinav Raut
0737d22374 Merge pull request #107 from ketan-10/fix/disable-crowdin-workflows-on-forks
stop crowdin workflow on forks
2025-06-19 11:52:06 +05:30
ketan
d6af9d10ea stop crowdin workflow on forks 2025-06-19 02:22:26 +05:30
Abhinav Raut
6381fc23c2 Merge pull request #105 from abhinavxd/feat/api-user
feat: API key management for agents
2025-06-19 02:05:00 +05:30
Abhinav Raut
6bb5728665 check csrf only for state-changing methods 2025-06-19 01:53:19 +05:30
Abhinav Raut
2322ec33b0 update admin role permissions to include webhooks:manage permission 2025-06-19 01:00:36 +05:30
Abhinav Raut
9132e11458 refactor return envelope errors from handlers 2025-06-19 00:14:35 +05:30
Abhinav Raut
e70f92d377 remove validation tags from loginRequest struct 2025-06-18 23:58:07 +05:30
Abhinav Raut
591108f094 fix: reset recipients when no latest message is found 2025-06-18 01:31:15 +05:30
Abhinav Raut
1b2a5e4f36 feat: standardize API requests to use JSON instead of form data 2025-06-18 01:30:38 +05:30
Abhinav Raut
f613cc237b fix schema file 2025-06-16 23:49:41 +05:30
Abhinav Raut
c37258fccb feat: API key management for agents, api keys can now be generated for any agent in libredesk allowing programmatic access.
- Added endpoints to generate and revoke API keys for agents.
- Updated user model to include API key fields.
- Update authentication middleware to support API key validation.
- Modified database schema to accommodate API key fields.
- Updated frontend to manage API keys, including generation and revocation.
- Added localization strings for API key related messages.
2025-06-16 23:45:00 +05:30
Abhinav Raut
1879d9d22b docs: add webhooks feature to README for external system integration 2025-06-15 13:56:48 +05:30
Abhinav Raut
b369e2f56a Merge pull request #104 from abhinavxd/feat/webhooks
Feat: Webhooks
2025-06-15 13:46:48 +05:30
Abhinav Raut
ef56f1a74e feat: add webhooks documentation and navigation entry 2025-06-15 13:42:50 +05:30
Abhinav Raut
d274adb19b lower case webhook data table event label 2025-06-15 13:27:56 +05:30
Abhinav Raut
d31fcb00b6 refactor: simplify checkbox event handling in webhook form 2025-06-15 13:22:27 +05:30
Abhinav Raut
88d719ec4f fix remove unncessary indexes on webhooks table 2025-06-15 13:10:19 +05:30
Abhinav Raut
147180a536 remove unused headers column from webhooks table 2025-06-15 13:05:14 +05:30
Abhinav Raut
faa195f0a6 fix typo in prepared query 2025-06-15 12:43:28 +05:30
Abhinav Raut
4b0422d904 Reduce duplicate database conversation fetches by adding a new method EvaluateConversationUpdateRulesByID that accepts only a conversation ID instead of a conversation object, allowing the caller to control the behavior since the caller might already have the conversation object or might want a fresh fetch.
- Return early if user / team is already assigned to a conversation

- rename query get-pending-messages to get-outgoing-pending-messages to reflect purpose.
2025-06-15 12:43:01 +05:30
Abhinav Raut
9303997cea feat: update sidebar title translation to support pluralization for integration nav item 2025-06-14 22:28:46 +05:30
Abhinav Raut
aba07b3096 fix transalations for webhooks 2025-06-14 22:24:41 +05:30
Abhinav Raut
27aac88f53 update webhook payloads, send reduced payloads showing what changed instead of entire objects 2025-06-14 21:51:11 +05:30
Abhinav Raut
cb6b0e420b adds missing colunms from select query were missing for webhook events 2025-06-14 21:49:31 +05:30
Abhinav Raut
e004afd7d1 fix: remove unused email fields from Conversation model 2025-06-14 21:49:01 +05:30
Abhinav Raut
6a77d346dc feat: add version package to hold application version for build time configuration 2025-06-14 21:48:55 +05:30
Abhinav Raut
60c89cb617 use http client with set timeout for webhook requests
remove db calls from `TriggerEvent`
2025-06-14 21:48:46 +05:30
Abhinav Raut
b7d4b187e8 fix: adds missing conversation update rule evaluation missing for event EventConversationTeamAssigned
refactor code
2025-06-14 19:55:24 +05:30
Abhinav Raut
2bf45f32de fix: remove headers field from webhook model and related queries 2025-06-14 15:03:11 +05:30
Abhinav Raut
981372ab86 wip webhooks 2025-06-13 02:17:00 +05:30
690 changed files with 19864 additions and 4232 deletions

16
.github/ISSUE_TEMPLATE/confirmed-bug.md vendored Normal file
View File

@@ -0,0 +1,16 @@
---
name: Confirmed Bug Report
about: Report a confirmed bug in Libredesk
title: "[Bug] <brief summary>"
labels: bug
assignees: ""
---
**Version:**
- libredesk: [eg: v0.7.0]
**Description of the bug and steps to reproduce:**
A clear and concise description of what the bug is.
**Logs / Screenshots:**
Attach any relevant logs or screenshots to help diagnose the issue.

16
.github/ISSUE_TEMPLATE/possible-bug.md vendored Normal file
View File

@@ -0,0 +1,16 @@
---
name: Possible Bug Report
about: Something in Libredesk might be broken but needs confirmation
title: "[Possible Bug] <brief summary>"
labels: bug, needs-investigation
assignees: ""
---
**Version:**
- libredesk: [eg: v0.7.0]
**Description of the bug and steps to reproduce:**
A clear and concise description of what the bug is.
**Logs / Screenshots:**
Attach any relevant logs or screenshots to help diagnose the issue.

View File

@@ -12,6 +12,8 @@ on:
jobs:
crowdin:
runs-on: ubuntu-latest
# Only run on the original repository, not forks
if: github.event.repository.fork == false
steps:
- name: Checkout
uses: actions/checkout@v4

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,32 +28,61 @@ 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
run-backend:
@echo "→ Running backend..."
CGO_ENABLED=0 go run -ldflags="-s -w -X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}' -X 'main.frontendDir=frontend/dist'" cmd/*.go
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
build-backend: $(STUFFBIN)
@echo "→ Building backend..."
@CGO_ENABLED=0 go build -a\
-ldflags="-X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}' -s -w" \
@CGO_ENABLED=0 go build -a \
-ldflags="-X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}' -X 'github.com/abhinavxd/libredesk/internal/version.Version=${VERSION}' -s -w" \
-o ${BIN} cmd/*.go
# Main build target: builds both frontend and backend, then stuffs static assets into the binary.

View File

@@ -3,19 +3,17 @@
# 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**
Libredesk supports multiple shares inboxes, letting you manage conversations across teams effortlessly.
Libredesk supports multiple shared inboxes, letting you manage conversations across teams effortlessly.
- **Granular Permissions**
Create custom roles with granular permissions for teams and individual agents.
- **Smart Automation**
@@ -36,6 +34,8 @@ Visit [libredesk.io](https://libredesk.io) for more info. Check out the [**Live
Instantly rewrite responses with AI to make them more friendly, professional, or polished.
- **Activity logs**
Track all actions performed by agents and admins—updates and key events across the system—for auditing and accountability.
- **Webhooks**
Integrate with external systems using real-time HTTP notifications for conversation and message events.
- **Command Bar**
Opens with a simple shortcut (CTRL+K) and lets you quickly perform actions on conversations.
@@ -65,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)
__________________
@@ -76,12 +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.
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

View File

@@ -5,6 +5,11 @@ import (
"github.com/zerodha/fastglue"
)
type aiCompletionReq struct {
PromptKey string `json:"prompt_key"`
Content string `json:"content"`
}
type providerUpdateReq struct {
Provider string `json:"provider"`
APIKey string `json:"api_key"`
@@ -13,11 +18,15 @@ type providerUpdateReq struct {
// handleAICompletion handles AI completion requests
func handleAICompletion(r *fastglue.Request) error {
var (
app = r.Context.(*App)
promptKey = string(r.RequestCtx.PostArgs().Peek("prompt_key"))
content = string(r.RequestCtx.PostArgs().Peek("content"))
app = r.Context.(*App)
req = aiCompletionReq{}
)
resp, err := app.ai.Completion(promptKey, content)
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))
}
resp, err := app.ai.Completion(req.PromptKey, req.Content)
if err != nil {
return sendErrorEnvelope(r, err)
}

View File

@@ -9,6 +9,10 @@ import (
"github.com/zerodha/fastglue"
)
type updateAutomationRuleExecutionModeReq struct {
Mode string `json:"mode"`
}
// handleGetAutomationRules gets all automation rules
func handleGetAutomationRules(r *fastglue.Request) error {
var (
@@ -41,10 +45,11 @@ func handleToggleAutomationRule(r *fastglue.Request) error {
app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if err := app.automation.ToggleRule(id); err != nil {
toggledRule, err := app.automation.ToggleRule(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
return r.SendEnvelope(toggledRule)
}
// handleUpdateAutomationRule updates an automation rule
@@ -62,10 +67,11 @@ func handleUpdateAutomationRule(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if err = app.automation.UpdateRule(id, rule); err != nil {
updatedRule, err := app.automation.UpdateRule(id, rule)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
return r.SendEnvelope(updatedRule)
}
// handleCreateAutomationRule creates a new automation rule
@@ -77,10 +83,11 @@ func handleCreateAutomationRule(r *fastglue.Request) error {
if err := r.Decode(&rule, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if err := app.automation.CreateRule(rule); err != nil {
createdRule, err := app.automation.CreateRule(rule)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
return r.SendEnvelope(createdRule)
}
// handleDeleteAutomationRule deletes an automation rule
@@ -118,14 +125,20 @@ func handleUpdateAutomationRuleWeights(r *fastglue.Request) error {
// handleUpdateAutomationRuleExecutionMode updates the execution mode of the automation rules for a given type
func handleUpdateAutomationRuleExecutionMode(r *fastglue.Request) error {
var (
app = r.Context.(*App)
mode = string(r.RequestCtx.PostArgs().Peek("mode"))
app = r.Context.(*App)
req = updateAutomationRuleExecutionModeReq{}
)
if mode != amodels.ExecutionModeAll && mode != amodels.ExecutionModeFirstMatch {
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 req.Mode != amodels.ExecutionModeAll && req.Mode != amodels.ExecutionModeFirstMatch {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("automation.invalidRuleExecutionMode"), nil, envelope.InputError)
}
// Only new conversation rules can be updated as they are the only ones that have execution mode.
if err := app.automation.UpdateRuleExecutionMode(amodels.RuleTypeNewConversation, mode); err != nil {
if err := app.automation.UpdateRuleExecutionMode(amodels.RuleTypeNewConversation, req.Mode); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)

View File

@@ -55,11 +55,12 @@ func handleCreateBusinessHours(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
}
if err := app.businessHours.Create(businessHours.Name, businessHours.Description, businessHours.IsAlwaysOpen, businessHours.Hours, businessHours.Holidays); err != nil {
createdBusinessHours, err := app.businessHours.Create(businessHours.Name, businessHours.Description, businessHours.IsAlwaysOpen, businessHours.Hours, businessHours.Holidays)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
return r.SendEnvelope(createdBusinessHours)
}
// handleDeleteBusinessHour deletes the business hour with the given id.
@@ -93,8 +94,9 @@ func handleUpdateBusinessHours(r *fastglue.Request) error {
if businessHours.Name == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`name`"), nil, envelope.InputError)
}
if err := app.businessHours.Update(id, businessHours.Name, businessHours.Description, businessHours.IsAlwaysOpen, businessHours.Hours, businessHours.Holidays); err != nil {
updatedBusinessHours, err := app.businessHours.Update(id, businessHours.Name, businessHours.Description, businessHours.IsAlwaysOpen, businessHours.Hours, businessHours.Holidays)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
return r.SendEnvelope(updatedBusinessHours)
}

1138
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

@@ -14,6 +14,14 @@ import (
"github.com/zerodha/fastglue"
)
type createContactNoteReq struct {
Note string `json:"note"`
}
type blockContactReq struct {
Enabled bool `json:"enabled"`
}
// handleGetContacts returns a list of contacts from the database.
func handleGetContacts(r *fastglue.Request) error {
var (
@@ -95,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 {
@@ -108,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 = ""
@@ -138,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 {
@@ -156,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.
@@ -185,15 +199,23 @@ func handleCreateContactNote(r *fastglue.Request) error {
app = r.Context.(*App)
contactID, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
auser = r.RequestCtx.UserValue("user").(amodels.User)
note = string(r.RequestCtx.PostArgs().Peek("note"))
req = createContactNoteReq{}
)
if len(note) == 0 {
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, 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.
@@ -227,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)
}
@@ -238,13 +262,32 @@ func handleBlockContact(r *fastglue.Request) error {
var (
app = r.Context.(*App)
contactID, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
enabled = r.RequestCtx.PostArgs().GetBool("enabled")
auser = r.RequestCtx.UserValue("user").(amodels.User)
req = blockContactReq{}
)
if contactID <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := app.user.ToggleEnabled(contactID, models.UserTypeContact, enabled); err != nil {
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))
}
app.lo.Info("setting contact block status", "contact_id", contactID, "enabled", req.Enabled, "actor_id", auser.ID)
contact, err := app.user.GetContact(contactID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
if err := app.user.ToggleEnabled(contactID, contact.Type, req.Enabled); err != nil {
return sendErrorEnvelope(r, err)
}
contact, err = app.user.GetContact(contactID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(contact)
}

View File

@@ -1,7 +1,6 @@
package main
import (
"encoding/json"
"strconv"
"time"
@@ -13,21 +12,44 @@ import (
medModels "github.com/abhinavxd/libredesk/internal/media/models"
"github.com/abhinavxd/libredesk/internal/stringutil"
umodels "github.com/abhinavxd/libredesk/internal/user/models"
wmodels "github.com/abhinavxd/libredesk/internal/webhook/models"
"github.com/valyala/fasthttp"
"github.com/volatiletech/null/v9"
"github.com/zerodha/fastglue"
)
type assigneeChangeReq struct {
AssigneeID int `json:"assignee_id"`
}
type teamAssigneeChangeReq struct {
AssigneeID int `json:"assignee_id"`
}
type priorityUpdateReq struct {
Priority string `json:"priority"`
}
type statusUpdateReq struct {
Status string `json:"status"`
SnoozedUntil string `json:"snoozed_until,omitempty"`
}
type tagsUpdateReq struct {
Tags []string `json:"tags"`
}
type createConversationRequest struct {
InboxID int `json:"inbox_id" form:"inbox_id"`
AssignedAgentID int `json:"agent_id" form:"agent_id"`
AssignedTeamID int `json:"team_id" form:"team_id"`
Email string `json:"contact_email" form:"contact_email"`
FirstName string `json:"first_name" form:"first_name"`
LastName string `json:"last_name" form:"last_name"`
Subject string `json:"subject" form:"subject"`
Content string `json:"content" form:"content"`
Attachments []int `json:"attachments" form:"attachments"`
InboxID int `json:"inbox_id"`
AssignedAgentID int `json:"agent_id"`
AssignedTeamID int `json:"team_id"`
Email string `json:"contact_email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Subject string `json:"subject"`
Content string `json:"content"`
Attachments []int `json:"attachments"`
Initiator string `json:"initiator"` // "contact" | "agent"
}
// handleGetAllConversations retrieves all conversations.
@@ -252,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)
}
@@ -303,13 +325,15 @@ func handleGetConversationParticipants(r *fastglue.Request) error {
// handleUpdateUserAssignee updates the user assigned to a conversation.
func handleUpdateUserAssignee(r *fastglue.Request) error {
var (
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
assigneeID = r.RequestCtx.PostArgs().GetUintOrZero("assignee_id")
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
req = assigneeChangeReq{}
)
if assigneeID == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`assignee_id`"), nil, envelope.InputError)
if err := r.Decode(&req, "json"); err != nil {
app.lo.Error("error decoding assignee change request", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
user, err := app.user.GetAgent(auser.ID, "")
@@ -317,17 +341,19 @@ func handleUpdateUserAssignee(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
_, err = enforceConversationAccess(app, uuid, user)
conversation, err := enforceConversationAccess(app, uuid, user)
if err != nil {
return sendErrorEnvelope(r, err)
}
if err := app.conversation.UpdateConversationUserAssignee(uuid, assigneeID, user); err != nil {
return sendErrorEnvelope(r, err)
// Already assigned?
if conversation.AssignedUserID.Int == req.AssigneeID {
return r.SendEnvelope(true)
}
// Evaluate automation rules.
app.automation.EvaluateConversationUpdateRules(uuid, models.EventConversationUserAssigned)
if err := app.conversation.UpdateConversationUserAssignee(uuid, req.AssigneeID, user); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
@@ -338,12 +364,16 @@ func handleUpdateTeamAssignee(r *fastglue.Request) error {
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
req = teamAssigneeChangeReq{}
)
assigneeID, err := r.RequestCtx.PostArgs().GetUint("assignee_id")
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`assignee_id`"), nil, envelope.InputError)
if err := r.Decode(&req, "json"); err != nil {
app.lo.Error("error decoding team assignee change request", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
assigneeID := req.AssigneeID
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
@@ -354,28 +384,37 @@ func handleUpdateTeamAssignee(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
_, err = enforceConversationAccess(app, uuid, user)
conversation, err := enforceConversationAccess(app, uuid, user)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Already assigned?
if conversation.AssignedTeamID.Int == assigneeID {
return r.SendEnvelope(true)
}
if err := app.conversation.UpdateConversationTeamAssignee(uuid, assigneeID, user); err != nil {
return sendErrorEnvelope(r, err)
}
// Evaluate automation rules on team assignment.
app.automation.EvaluateConversationUpdateRules(uuid, models.EventConversationTeamAssigned)
return r.SendEnvelope(true)
}
// handleUpdateConversationPriority updates the priority of a conversation.
func handleUpdateConversationPriority(r *fastglue.Request) error {
var (
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
priority = string(r.RequestCtx.PostArgs().Peek("priority"))
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
req = priorityUpdateReq{}
)
if err := r.Decode(&req, "json"); err != nil {
app.lo.Error("error decoding priority update request", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
priority := req.Priority
if priority == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`priority`"), nil, envelope.InputError)
}
@@ -392,22 +431,26 @@ func handleUpdateConversationPriority(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
// Evaluate automation rules.
app.automation.EvaluateConversationUpdateRules(uuid, models.EventConversationPriorityChange)
return r.SendEnvelope(true)
}
// handleUpdateConversationStatus updates the status of a conversation.
func handleUpdateConversationStatus(r *fastglue.Request) error {
var (
app = r.Context.(*App)
status = string(r.RequestCtx.PostArgs().Peek("status"))
snoozedUntil = string(r.RequestCtx.PostArgs().Peek("snoozed_until"))
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
req = statusUpdateReq{}
)
if err := r.Decode(&req, "json"); err != nil {
app.lo.Error("error decoding status update request", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
status := req.Status
snoozedUntil := req.SnoozedUntil
// Validate inputs
if status == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`status`"), nil, envelope.InputError)
@@ -432,19 +475,11 @@ 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)
}
// Evaluate automation rules.
app.automation.EvaluateConversationUpdateRules(uuid, models.EventConversationStatusChange)
// If status is `Resolved`, send CSAT survey if enabled on inbox.
if status == cmodels.StatusResolved {
// Check if CSAT is enabled on the inbox and send CSAT survey message.
@@ -464,18 +499,19 @@ func handleUpdateConversationStatus(r *fastglue.Request) error {
// handleUpdateConversationtags updates conversation tags.
func handleUpdateConversationtags(r *fastglue.Request) error {
var (
app = r.Context.(*App)
tagNames = []string{}
tagJSON = r.RequestCtx.PostArgs().Peek("tags")
auser = r.RequestCtx.UserValue("user").(amodels.User)
uuid = r.RequestCtx.UserValue("uuid").(string)
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
uuid = r.RequestCtx.UserValue("uuid").(string)
req = tagsUpdateReq{}
)
if err := json.Unmarshal(tagJSON, &tagNames); err != nil {
app.lo.Error("error unmarshalling tags JSON", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
if err := r.Decode(&req, "json"); err != nil {
app.lo.Error("error decoding tags update request", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
tagNames := req.Tags
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
@@ -543,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.
@@ -582,7 +618,7 @@ func handleRemoveUserAssignee(r *fastglue.Request) error {
if err != nil {
return sendErrorEnvelope(r, err)
}
if err = app.conversation.RemoveConversationAssignee(uuid, "user"); err != nil {
if err = app.conversation.RemoveConversationAssignee(uuid, "user", user); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
@@ -603,20 +639,20 @@ func handleRemoveTeamAssignee(r *fastglue.Request) error {
if err != nil {
return sendErrorEnvelope(r, err)
}
if err = app.conversation.RemoveConversationAssignee(uuid, "team"); err != nil {
if err = app.conversation.RemoveConversationAssignee(uuid, "team", user); err != nil {
return sendErrorEnvelope(r, err)
}
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.
@@ -632,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, "")
@@ -703,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.
@@ -720,7 +747,44 @@ func handleCreateConversation(r *fastglue.Request) error {
app.conversation.UpdateConversationTeamAssignee(conversationUUID, req.AssignedTeamID, user)
}
// Send the created conversation back to the client.
conversation, _ := app.conversation.GetConversation(conversationID, "")
// Trigger webhook event for conversation created.
conversation, err := app.conversation.GetConversation(conversationID, "")
if err == nil {
app.webhook.TriggerEvent(wmodels.EventConversationCreated, conversation)
}
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

@@ -70,10 +70,11 @@ func handleCreateCustomAttribute(r *fastglue.Request) error {
if err := validateCustomAttribute(app, attribute); err != nil {
return sendErrorEnvelope(r, err)
}
if err := app.customAttribute.Create(attribute); err != nil {
createdAttr, err := app.customAttribute.Create(attribute)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
return r.SendEnvelope(createdAttr)
}
// handleUpdateCustomAttribute updates an existing custom attribute in the database.
@@ -92,10 +93,11 @@ func handleUpdateCustomAttribute(r *fastglue.Request) error {
if err := validateCustomAttribute(app, attribute); err != nil {
return sendErrorEnvelope(r, err)
}
if err = app.customAttribute.Update(id, attribute); err != nil {
updatedAttr, err := app.customAttribute.Update(id, attribute)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
return r.SendEnvelope(updatedAttr)
}
// handleDeleteCustomAttribute deletes a custom attribute from the database.

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"
@@ -15,7 +19,7 @@ import (
// initHandlers initializes the HTTP routes and handlers for the application.
func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
// Authentication.
g.POST("/api/v1/login", handleLogin)
g.POST("/api/v1/auth/login", handleLogin)
g.GET("/logout", auth(handleLogout))
g.GET("/api/v1/oidc/{id}/login", handleOIDCLogin)
g.GET("/api/v1/oidc/{id}/finish", handleOIDCCallback)
@@ -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"))
@@ -110,6 +116,8 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.POST("/api/v1/agents", perm(handleCreateAgent, "users:manage"))
g.PUT("/api/v1/agents/{id}", perm(handleUpdateAgent, "users:manage"))
g.DELETE("/api/v1/agents/{id}", perm(handleDeleteAgent, "users:manage"))
g.POST("/api/v1/agents/{id}/api-key", perm(handleGenerateAPIKey, "users:manage"))
g.DELETE("/api/v1/agents/{id}/api-key", perm(handleRevokeAPIKey, "users:manage"))
g.POST("/api/v1/agents/reset-password", tryAuth(handleResetPassword))
g.POST("/api/v1/agents/set-password", tryAuth(handleSetPassword))
@@ -151,12 +159,21 @@ 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"))
g.DELETE("/api/v1/roles/{id}", perm(handleDeleteRole, "roles:manage"))
// Webhooks.
g.GET("/api/v1/webhooks", perm(handleGetWebhooks, "webhooks:manage"))
g.GET("/api/v1/webhooks/{id}", perm(handleGetWebhook, "webhooks:manage"))
g.POST("/api/v1/webhooks", perm(handleCreateWebhook, "webhooks:manage"))
g.PUT("/api/v1/webhooks/{id}", perm(handleUpdateWebhook, "webhooks:manage"))
g.DELETE("/api/v1/webhooks/{id}", perm(handleDeleteWebhook, "webhooks:manage"))
g.PUT("/api/v1/webhooks/{id}/toggle", perm(handleToggleWebhook, "webhooks:manage"))
g.POST("/api/v1/webhooks/{id}/test", perm(handleTestWebhook, "webhooks:manage"))
// Reports.
g.GET("/api/v1/reports/overview/sla", perm(handleOverviewSLA, "reports:manage"))
g.GET("/api/v1/reports/overview/counts", perm(handleOverviewCounts, "reports:manage"))
@@ -198,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))
@@ -214,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)
@@ -252,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)
@@ -300,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)
}
@@ -47,11 +55,12 @@ func handleCreateInbox(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), err.Error(), envelope.InputError)
}
if err := app.inbox.Create(inbox); err != nil {
createdInbox, err := app.inbox.Create(inbox)
if err != nil {
return sendErrorEnvelope(r, err)
}
if err := validateInbox(app, inbox); err != nil {
if err := validateInbox(app, createdInbox); err != nil {
return sendErrorEnvelope(r, err)
}
@@ -59,7 +68,13 @@ func handleCreateInbox(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.couldNotReload", "name", "{globals.terms.inbox}"), nil, envelope.GeneralError)
}
return r.SendEnvelope(true)
// Clear passwords before returning.
if err := createdInbox.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(createdInbox)
}
// handleUpdateInbox updates an inbox
@@ -82,7 +97,7 @@ func handleUpdateInbox(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
err = app.inbox.Update(id, inbox)
updatedInbox, err := app.inbox.Update(id, inbox)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -91,7 +106,13 @@ func handleUpdateInbox(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.couldNotReload", "name", "{globals.terms.inbox}"), nil, envelope.GeneralError)
}
return r.SendEnvelope(inbox)
// Clear passwords before returning.
if err := updatedInbox.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(updatedInbox)
}
// handleToggleInbox toggles an inbox
@@ -105,7 +126,8 @@ func handleToggleInbox(r *fastglue.Request) error {
app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err = app.inbox.Toggle(id); err != nil {
toggledInbox, err := app.inbox.Toggle(id)
if err != nil {
return err
}
@@ -113,7 +135,13 @@ func handleToggleInbox(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.couldNotReload", "name", "{globals.terms.inbox}"), nil, envelope.GeneralError)
}
return r.SendEnvelope(true)
// Clear passwords before returning
if err := toggledInbox.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(toggledInbox)
}
// handleDeleteInbox deletes an inbox
@@ -134,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)
@@ -147,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"
@@ -45,6 +47,7 @@ import (
tmpl "github.com/abhinavxd/libredesk/internal/template"
"github.com/abhinavxd/libredesk/internal/user"
"github.com/abhinavxd/libredesk/internal/view"
"github.com/abhinavxd/libredesk/internal/webhook"
"github.com/abhinavxd/libredesk/internal/ws"
"github.com/jmoiron/sqlx"
"github.com/knadh/go-i18n"
@@ -131,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",
}
@@ -220,12 +224,32 @@ func initConversations(
csat *csat.Manager,
automationEngine *automation.Engine,
template *tmpl.Manager,
webhook *webhook.Manager,
) *conversation.Manager {
c, err := conversation.New(hub, i18n, notif, sla, status, priority, inboxStore, userStore, teamStore, mediaStore, settings, csat, automationEngine, template, conversation.Opts{
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)
@@ -248,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)
@@ -325,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 {
@@ -343,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
@@ -363,6 +388,9 @@ func getTmplFuncs(consts *constants) template.FuncMap {
"SiteName": func() string {
return consts.SiteName
},
"L": func() interface{} {
return i18n
},
}
}
@@ -379,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
}
@@ -391,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)
@@ -449,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)
@@ -570,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)
}
@@ -838,6 +901,23 @@ func initReport(db *sqlx.DB, i18n *i18n.I18n) *report.Manager {
return m
}
// initWebhook inits webhook manager.
func initWebhook(db *sqlx.DB, i18n *i18n.I18n) *webhook.Manager {
var lo = initLogger("webhook")
m, err := webhook.New(webhook.Opts{
DB: db,
Lo: lo,
I18n: i18n,
Workers: ko.MustInt("webhook.workers"),
QueueSize: ko.MustInt("webhook.queue_size"),
Timeout: ko.MustDuration("webhook.timeout"),
})
if err != nil {
log.Fatalf("error initializing webhook manager: %v", err)
}
return m
}
// initLogger initializes a logf logger.
func initLogger(src string) *logf.Logger {
lvl, env := ko.MustString("app.log_level"), ko.MustString("app.env")
@@ -875,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,23 +3,35 @@ 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"
)
type loginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
// handleLogin logs in the user and returns the user.
func handleLogin(r *fastglue.Request) error {
var (
app = r.Context.(*App)
email = string(r.RequestCtx.PostArgs().Peek("email"))
password = r.RequestCtx.PostArgs().Peek("password")
ip = realip.FromRequest(r.RequestCtx)
loginReq loginRequest
)
// Decode JSON request.
if err := r.Decode(&loginReq, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if loginReq.Email == "" || loginReq.Password == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.badRequest"), nil, envelope.InputError)
}
// Verify email and password.
user, err := app.user.VerifyPassword(email, password)
user, err := app.user.VerifyPassword(loginReq.Email, []byte(loginReq.Password))
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -29,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

@@ -81,11 +81,12 @@ func handleCreateMacro(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
if err := app.macro.Create(macro.Name, macro.MessageContent, macro.UserID, macro.TeamID, macro.Visibility, macro.VisibleWhen, macro.Actions); err != nil {
createdMacro, err := app.macro.Create(macro.Name, macro.MessageContent, macro.UserID, macro.TeamID, macro.Visibility, macro.VisibleWhen, macro.Actions)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(macro)
return r.SendEnvelope(createdMacro)
}
// handleUpdateMacro updates a macro.
@@ -109,11 +110,12 @@ func handleUpdateMacro(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
if err = app.macro.Update(id, macro.Name, macro.MessageContent, macro.UserID, macro.TeamID, macro.Visibility, macro.VisibleWhen, macro.Actions); err != nil {
updatedMacro, err := app.macro.Update(id, macro.Name, macro.MessageContent, macro.UserID, macro.TeamID, macro.Visibility, macro.VisibleWhen, macro.Actions)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(macro)
return r.SendEnvelope(updatedMacro)
}
// handleDeleteMacro deletes macro.

View File

@@ -35,12 +35,14 @@ 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"
"github.com/abhinavxd/libredesk/internal/team"
"github.com/abhinavxd/libredesk/internal/template"
"github.com/abhinavxd/libredesk/internal/user"
"github.com/abhinavxd/libredesk/internal/webhook"
"github.com/knadh/go-i18n"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
@@ -53,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
@@ -92,9 +95,13 @@ type App struct {
notifier *notifier.Service
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
}
@@ -191,21 +198,29 @@ func main() {
inbox = initInbox(db, i18n)
team = initTeam(db, i18n)
businessHours = initBusinessHours(db, i18n)
webhook = initWebhook(db, i18n)
user = initUser(i18n, db)
wsHub = initWS(user)
notifier = initNotifier()
automation = initAutomationEngine(db, i18n)
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)
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)
go sla.SendNotifications(ctx)
@@ -235,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),
@@ -243,6 +258,8 @@ func main() {
tag: initTag(db, i18n),
macro: initMacro(db, i18n),
ai: initAI(db, i18n),
webhook: webhook,
rateLimit: rateLimiter,
}
app.consts.Store(constants)
@@ -286,6 +303,8 @@ func main() {
autoassigner.Close()
colorlog.Red("Shutting down notifier...")
notifier.Close()
colorlog.Red("Shutting down webhook...")
webhook.Close()
colorlog.Red("Shutting down conversation...")
conversation.Close()
colorlog.Red("Shutting down SLA...")

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,11 +2,14 @@ package main
import (
"strconv"
"strings"
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
"github.com/abhinavxd/libredesk/internal/automation/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"
)
@@ -18,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.
@@ -42,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)
}
@@ -53,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,
@@ -90,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)
@@ -100,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)
@@ -151,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, "")
@@ -162,16 +203,27 @@ func handleSendMessage(r *fastglue.Request) error {
media = append(media, m)
}
if req.Private {
if err := app.conversation.SendPrivateNote(media, user.ID, cuuid, req.Message); err != nil {
// 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)
}
} else {
if err := app.conversation.SendReply(media, conv.InboxID, user.ID, cuuid, req.Message, req.To, req.CC, req.BCC, map[string]any{} /**meta**/); err != nil {
return sendErrorEnvelope(r, err)
}
// Evaluate automation rules.
app.automation.EvaluateConversationUpdateRules(cuuid, models.EventConversationMessageOutgoing)
return r.SendEnvelope(message)
}
return r.SendEnvelope(true)
// Send private note.
if req.Private {
message, err := app.conversation.SendPrivateNote(media, user.ID, cuuid, req.Message)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(message)
}
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)
}
return r.SendEnvelope(message)
}

View File

@@ -6,30 +6,80 @@ import (
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/user/models"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
"github.com/zerodha/simplesessions/v3"
)
// authenticateUser handles both API key and session-based authentication
// Returns the authenticated user or an error
// For session-based auth, CSRF is checked for POST/PUT/DELETE requests
func authenticateUser(r *fastglue.Request, app *App) (models.User, error) {
var user models.User
// Check for Authorization header first (API key authentication)
apiKey, apiSecret, err := r.ParseAuthHeader(fastglue.AuthBasic | fastglue.AuthToken)
if err == nil && len(apiKey) > 0 && len(apiSecret) > 0 {
user, err = app.user.ValidateAPIKey(string(apiKey), string(apiSecret))
if err != nil {
return user, err
}
return user, nil
}
// Session-based authentication - Check CSRF first.
method := string(r.RequestCtx.Method())
if method == "POST" || method == "PUT" || method == "DELETE" {
cookieToken := string(r.RequestCtx.Request.Header.Cookie("csrf_token"))
hdrToken := string(r.RequestCtx.Request.Header.Peek("X-CSRFTOKEN"))
// Match CSRF token from cookie and header.
if cookieToken == "" || hdrToken == "" || cookieToken != hdrToken {
app.lo.Error("csrf token mismatch", "method", method, "cookie_token", cookieToken, "header_token", hdrToken)
return user, envelope.NewError(envelope.PermissionError, app.i18n.T("auth.csrfTokenMismatch"), nil)
}
}
// Validate session and fetch user.
sessUser, err := app.auth.ValidateSession(r)
if err != nil || sessUser.ID <= 0 {
app.lo.Error("error validating session", "error", err)
return user, envelope.NewError(envelope.GeneralError, app.i18n.T("auth.invalidOrExpiredSession"), nil)
}
// Get agent user from cache or load it.
user, err = app.user.GetAgentCachedOrLoad(sessUser.ID)
if err != nil {
return user, err
}
// Destroy session if user is disabled.
if !user.Enabled {
if err := app.auth.DestroySession(r); err != nil {
app.lo.Error("error destroying session", "error", err)
}
return user, envelope.NewError(envelope.PermissionError, app.i18n.T("user.accountDisabled"), nil)
}
return user, nil
}
// tryAuth attempts to authenticate the user and add them to the context but doesn't enforce authentication.
// Handlers can check if user exists in context optionally.
// Supports both API key authentication (Authorization header) and session-based authentication.
func tryAuth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
return func(r *fastglue.Request) error {
app := r.Context.(*App)
// Try to validate session without returning error.
userSession, err := app.auth.ValidateSession(r)
if err != nil || userSession.ID <= 0 {
return handler(r)
}
// Try to get user.
user, err := app.user.GetAgentCachedOrLoad(userSession.ID)
// Try to authenticate user using shared authentication logic, but don't return errors
user, err := authenticateUser(r, app)
if err != nil {
// Authentication failed, but this is optional, so continue without user
return handler(r)
}
// Set user in context if found.
// Set user in context if authentication succeeded.
r.RequestCtx.SetUserValue("user", amodels.User{
ID: user.ID,
Email: user.Email.String,
@@ -41,23 +91,42 @@ func tryAuth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
}
}
// auth validates the session and adds the user to the request context.
// auth validates the session or API key and adds the user to the request context.
// Supports both API key authentication (Authorization header) and session-based authentication.
func auth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
return func(r *fastglue.Request) error {
var app = r.Context.(*App)
// Validate session and fetch user.
userSession, err := app.auth.ValidateSession(r)
if err != nil || userSession.ID <= 0 {
app.lo.Error("error validating session", "error", err)
return r.SendErrorEnvelope(http.StatusUnauthorized, app.i18n.T("auth.invalidOrExpiredSession"), nil, envelope.GeneralError)
// 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 {
if envErr, ok := err.(envelope.Error); ok {
if envErr.ErrorType == envelope.PermissionError {
return r.SendErrorEnvelope(http.StatusForbidden, envErr.Message, nil, envelope.PermissionError)
}
return r.SendErrorEnvelope(http.StatusUnauthorized, envErr.Message, nil, envelope.GeneralError)
}
return sendErrorEnvelope(r, err)
}
// Set user in the request context.
user, err := app.user.GetAgentCachedOrLoad(userSession.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
r.RequestCtx.SetUserValue("user", amodels.User{
ID: user.ID,
Email: user.Email.String,
@@ -69,41 +138,22 @@ func auth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
}
}
// perm matches the CSRF token and checks if the user has the required permission to access the endpoint.
// and sets the user in the request context.
// perm checks if the user has the required permission to access the endpoint.
// Supports both API key authentication (Authorization header) and session-based authentication.
func perm(handler fastglue.FastRequestHandler, perm string) fastglue.FastRequestHandler {
return func(r *fastglue.Request) error {
var (
app = r.Context.(*App)
cookieToken = string(r.RequestCtx.Request.Header.Cookie("csrf_token"))
hdrToken = string(r.RequestCtx.Request.Header.Peek("X-CSRFTOKEN"))
)
var app = r.Context.(*App)
// Match CSRF token from cookie and header.
if cookieToken == "" || hdrToken == "" || cookieToken != hdrToken {
app.lo.Error("csrf token mismatch", "cookie_token", cookieToken, "header_token", hdrToken)
return r.SendErrorEnvelope(http.StatusForbidden, app.i18n.T("auth.csrfTokenMismatch"), nil, envelope.PermissionError)
}
// Validate session and fetch user.
sessUser, err := app.auth.ValidateSession(r)
if err != nil || sessUser.ID <= 0 {
app.lo.Error("error validating session", "error", err)
return r.SendErrorEnvelope(http.StatusUnauthorized, app.i18n.T("auth.invalidOrExpiredSession"), nil, envelope.GeneralError)
}
// Get agent user from cache or load it.
user, err := app.user.GetAgentCachedOrLoad(sessUser.ID)
// Authenticate user using shared authentication logic
user, err := authenticateUser(r, app)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Destroy session if user is disabled.
if !user.Enabled {
if err := app.auth.DestroySession(r); err != nil {
app.lo.Error("error destroying session", "error", err)
if envErr, ok := err.(envelope.Error); ok {
if envErr.ErrorType == envelope.PermissionError {
return r.SendErrorEnvelope(http.StatusForbidden, envErr.Message, nil, envelope.PermissionError)
}
return r.SendErrorEnvelope(http.StatusUnauthorized, envErr.Message, nil, envelope.GeneralError)
}
return r.SendErrorEnvelope(http.StatusUnauthorized, app.i18n.T("user.accountDisabled"), nil, envelope.PermissionError)
return sendErrorEnvelope(r, err)
}
// Split the permission string into object and action and enforce it.

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)
@@ -65,7 +55,8 @@ func handleCreateOIDC(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
if err := app.oidc.Create(req); err != nil {
createdOIDC, err := app.oidc.Create(req)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -73,7 +64,11 @@ 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)
}
return r.SendEnvelope("OIDC created successfully")
// Clear client secret before returning
createdOIDC.ClientSecret = strings.Repeat(stringutil.PasswordDummy, 10)
return r.SendEnvelope(createdOIDC)
}
// handleUpdateOIDC updates an OIDC record.
@@ -96,7 +91,8 @@ func handleUpdateOIDC(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
if err = app.oidc.Update(id, req); err != nil {
updatedOIDC, err := app.oidc.Update(id, req)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -104,7 +100,11 @@ 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)
}
return r.SendEnvelope(true)
// Clear client secret before returning
updatedOIDC.ClientSecret = strings.Repeat(stringutil.PasswordDummy, 10)
return r.SendEnvelope(updatedOIDC)
}
// handleDeleteOIDC deletes an OIDC record.

View File

@@ -55,10 +55,11 @@ func handleCreateRole(r *fastglue.Request) error {
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 err := app.role.Create(req); err != nil {
createdRole, err := app.role.Create(req)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
return r.SendEnvelope(createdRole)
}
// handleUpdateRole updates a role
@@ -71,8 +72,9 @@ func handleUpdateRole(r *fastglue.Request) error {
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 err := app.role.Update(id, req); err != nil {
updatedRole, err := app.role.Update(id, req)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
return r.SendEnvelope(updatedRole)
}

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

@@ -54,11 +54,12 @@ func handleCreateSLA(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
if err := app.sla.Create(sla.Name, sla.Description, sla.FirstResponseTime, sla.ResolutionTime, sla.NextResponseTime, sla.Notifications); err != nil {
createdSLA, err := app.sla.Create(sla.Name, sla.Description, sla.FirstResponseTime, sla.ResolutionTime, sla.NextResponseTime, sla.Notifications)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("SLA created successfully.")
return r.SendEnvelope(createdSLA)
}
// handleUpdateSLA updates the SLA with the given ID.
@@ -81,11 +82,12 @@ func handleUpdateSLA(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
if err := app.sla.Update(id, sla.Name, sla.Description, sla.FirstResponseTime, sla.ResolutionTime, sla.NextResponseTime, sla.Notifications); err != nil {
updatedSLA, err := app.sla.Update(id, sla.Name, sla.Description, sla.FirstResponseTime, sla.ResolutionTime, sla.NextResponseTime, sla.Notifications)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
return r.SendEnvelope(updatedSLA)
}
// handleDeleteSLA deletes the SLA with the given ID.

View File

@@ -33,12 +33,12 @@ func handleCreateStatus(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
}
err := app.status.Create(status.Name)
createdStatus, err := app.status.Create(status.Name)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
return r.SendEnvelope(createdStatus)
}
func handleDeleteStatus(r *fastglue.Request) error {
@@ -74,10 +74,10 @@ func handleUpdateStatus(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
}
err = app.status.Update(id, status.Name)
updatedStatus, err := app.status.Update(id, status.Name)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
return r.SendEnvelope(updatedStatus)
}

View File

@@ -35,11 +35,12 @@ func handleCreateTag(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
}
if err := app.tag.Create(tag.Name); err != nil {
createdTag, err := app.tag.Create(tag.Name)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
return r.SendEnvelope(createdTag)
}
// handleDeleteTag deletes a tag from the database.
@@ -78,9 +79,10 @@ func handleUpdateTag(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
}
if err = app.tag.Update(id, tag.Name); err != nil {
updatedTag, err := app.tag.Update(id, tag.Name)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
return r.SendEnvelope(updatedTag)
}

View File

@@ -4,8 +4,8 @@ import (
"strconv"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/team/models"
"github.com/valyala/fasthttp"
"github.com/volatiletech/null/v9"
"github.com/zerodha/fastglue"
)
@@ -52,41 +52,42 @@ func handleGetTeam(r *fastglue.Request) error {
// handleCreateTeam creates a new team.
func handleCreateTeam(r *fastglue.Request) error {
var (
app = r.Context.(*App)
name = string(r.RequestCtx.PostArgs().Peek("name"))
timezone = string(r.RequestCtx.PostArgs().Peek("timezone"))
emoji = string(r.RequestCtx.PostArgs().Peek("emoji"))
conversationAssignmentType = string(r.RequestCtx.PostArgs().Peek("conversation_assignment_type"))
businessHrsID, _ = strconv.Atoi(string(r.RequestCtx.PostArgs().Peek("business_hours_id")))
slaPolicyID, _ = strconv.Atoi(string(r.RequestCtx.PostArgs().Peek("sla_policy_id")))
maxAutoAssignedConversations, _ = strconv.Atoi(string(r.RequestCtx.PostArgs().Peek("max_auto_assigned_conversations")))
app = r.Context.(*App)
req = models.Team{}
)
if err := app.team.Create(name, timezone, conversationAssignmentType, null.NewInt(businessHrsID, businessHrsID != 0), null.NewInt(slaPolicyID, slaPolicyID != 0), emoji, maxAutoAssignedConversations); err != nil {
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))
}
createdTeam, err := app.team.Create(req.Name, req.Timezone, req.ConversationAssignmentType, req.BusinessHoursID, req.SLAPolicyID, req.Emoji.String, req.MaxAutoAssignedConversations)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
return r.SendEnvelope(createdTeam)
}
// handleUpdateTeam updates an existing team.
func handleUpdateTeam(r *fastglue.Request) error {
var (
app = r.Context.(*App)
name = string(r.RequestCtx.PostArgs().Peek("name"))
timezone = string(r.RequestCtx.PostArgs().Peek("timezone"))
emoji = string(r.RequestCtx.PostArgs().Peek("emoji"))
conversationAssignmentType = string(r.RequestCtx.PostArgs().Peek("conversation_assignment_type"))
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
businessHrsID, _ = strconv.Atoi(string(r.RequestCtx.PostArgs().Peek("business_hours_id")))
slaPolicyID, _ = strconv.Atoi(string(r.RequestCtx.PostArgs().Peek("sla_policy_id")))
maxAutoAssignedConversations, _ = strconv.Atoi(string(r.RequestCtx.PostArgs().Peek("max_auto_assigned_conversations")))
app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
req = models.Team{}
)
if id < 1 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid team `id`", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := app.team.Update(id, name, timezone, conversationAssignmentType, null.NewInt(businessHrsID, businessHrsID != 0), null.NewInt(slaPolicyID, slaPolicyID != 0), emoji, maxAutoAssignedConversations); err != nil {
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))
}
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)
}
return r.SendEnvelope(true)
return r.SendEnvelope(updatedTeam)
}
// handleDeleteTeam deletes a team

View File

@@ -53,10 +53,11 @@ func handleCreateTemplate(r *fastglue.Request) error {
if req.Name == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
}
if err := app.tmpl.Create(req); err != nil {
template, err := app.tmpl.Create(req)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
return r.SendEnvelope(template)
}
// handleUpdateTemplate updates a template.
@@ -76,10 +77,11 @@ func handleUpdateTemplate(r *fastglue.Request) error {
if req.Name == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
}
if err = app.tmpl.Update(id, req); err != nil {
updatedTemplate, err := app.tmpl.Update(id, req)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
return r.SendEnvelope(updatedTemplate)
}
// handleDeleteTemplate deletes a template.

View File

@@ -34,6 +34,9 @@ var migList = []migFunc{
{"v0.4.0", migrations.V0_4_0},
{"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,11 +26,34 @@ const (
maxAvatarSizeMB = 2
)
type resetPasswordRequest struct {
Email string `json:"email"`
}
type setPasswordRequest struct {
Token string `json:"token"`
Password string `json:"password"`
}
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)
@@ -50,9 +73,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)
@@ -67,48 +88,56 @@ func handleGetAgent(r *fastglue.Request) error {
// handleUpdateAgentAvailability updates the current agent availability.
func handleUpdateAgentAvailability(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
status = string(r.RequestCtx.PostArgs().Peek("status"))
ip = realip.FromRequest(r.RequestCtx)
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
ip = realip.FromRequest(r.RequestCtx)
availReq availabilityRequest
)
// Decode JSON request
if err := r.Decode(&availReq, "json"); err != nil {
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)
}
// Same status?
if agent.AvailabilityStatus == status {
return r.SendEnvelope(true)
if agent.AvailabilityStatus == availReq.Status {
return r.SendEnvelope(agent)
}
// Update availability status.
if err := app.user.UpdateAvailability(auser.ID, status); err != nil {
// Update availability status
if err := app.user.UpdateAvailability(auser.ID, availReq.Status); err != nil {
return sendErrorEnvelope(r, err)
}
// Skip activity log if agent returns online from away (to avoid spam).
if !(agent.AvailabilityStatus == models.Away && status == models.Online) {
if err := app.activityLog.UserAvailability(auser.ID, auser.Email, status, ip, "", 0); err != nil {
if !(agent.AvailabilityStatus == models.Away && availReq.Status == models.Online) {
if err := app.activityLog.UserAvailability(auser.ID, auser.Email, availReq.Status, ip, "", 0); err != nil {
app.lo.Error("error creating activity log", "error", 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)
}
// 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)
}
@@ -121,11 +150,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)
@@ -136,54 +160,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)
}
@@ -191,31 +214,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))
@@ -224,25 +252,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, "")
@@ -251,8 +267,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)
}
@@ -260,18 +276,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.
@@ -351,22 +373,26 @@ func handleDeleteCurrentAgentAvatar(r *fastglue.Request) error {
func handleResetPassword(r *fastglue.Request) error {
var (
app = r.Context.(*App)
p = r.RequestCtx.PostArgs()
auser, ok = r.RequestCtx.UserValue("user").(amodels.User)
email = string(p.Peek("email"))
resetReq resetPasswordRequest
)
if ok && auser.ID > 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("user.userAlreadyLoggedIn"), nil, envelope.InputError)
}
if email == "" {
// Decode JSON request
if err := r.Decode(&resetReq, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if resetReq.Email == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`email`"), nil, envelope.InputError)
}
agent, err := app.user.GetAgent(0, email)
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)
@@ -401,20 +427,22 @@ func handleSetPassword(r *fastglue.Request) error {
var (
app = r.Context.(*App)
agent, ok = r.RequestCtx.UserValue("user").(amodels.User)
p = r.RequestCtx.PostArgs()
password = string(p.Peek("password"))
token = string(p.Peek("token"))
req setPasswordRequest
)
if ok && agent.ID > 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("user.userAlreadyLoggedIn"), nil, envelope.InputError)
}
if password == "" {
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 req.Password == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "{globals.terms.password}"), nil, envelope.InputError)
}
if err := app.user.ResetPassword(token, password); err != nil {
if err := app.user.ResetPassword(req.Token, req.Password); err != nil {
return sendErrorEnvelope(r, err)
}
@@ -422,13 +450,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()
@@ -445,7 +473,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)),
@@ -462,25 +490,110 @@ 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)
}
return nil
}
// handleGenerateAPIKey generates a new API key for a user
func handleGenerateAPIKey(r *fastglue.Request) error {
var (
app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
// Check if user exists
user, err := app.user.GetAgent(id, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
// Generate API key and secret
apiKey, apiSecret, err := app.user.GenerateAPIKey(user.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Return the API key and secret (only shown once)
response := struct {
APIKey string `json:"api_key"`
APISecret string `json:"api_secret"`
}{
APIKey: apiKey,
APISecret: apiSecret,
}
return r.SendEnvelope(response)
}
// handleRevokeAPIKey revokes a user's API key
func handleRevokeAPIKey(r *fastglue.Request) error {
var (
app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
// Check if user exists
_, err := app.user.GetAgent(id, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
// Revoke API key
if err := app.user.RevokeAPIKey(id); err != nil {
return sendErrorEnvelope(r, err)
}
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
}

View File

@@ -47,10 +47,11 @@ func handleCreateUserView(r *fastglue.Request) error {
if string(view.Filters) == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`Filters`"), nil, envelope.InputError)
}
if err := app.view.Create(view.Name, view.Filters, user.ID); err != nil {
createdView, err := app.view.Create(view.Name, view.Filters, user.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
return r.SendEnvelope(createdView)
}
// handleDeleteUserView deletes a view for a user.
@@ -111,8 +112,9 @@ func handleUpdateUserView(r *fastglue.Request) error {
if v.UserID != user.ID {
return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.PermissionError)
}
if err = app.view.Update(id, view.Name, view.Filters); err != nil {
updatedView, err := app.view.Update(id, view.Name, view.Filters)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
return r.SendEnvelope(updatedView)
}

191
cmd/webhooks.go Normal file
View File

@@ -0,0 +1,191 @@
package main
import (
"strconv"
"strings"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/stringutil"
"github.com/abhinavxd/libredesk/internal/webhook/models"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
// handleGetWebhooks returns all webhooks from the database.
func handleGetWebhooks(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
webhooks, err := app.webhook.GetAll()
if err != nil {
return sendErrorEnvelope(r, err)
}
// Hide secrets.
for i := range webhooks {
if webhooks[i].Secret != "" {
webhooks[i].Secret = strings.Repeat(stringutil.PasswordDummy, 10)
}
}
return r.SendEnvelope(webhooks)
}
// handleGetWebhook returns a specific webhook by ID.
func handleGetWebhook(r *fastglue.Request) error {
var (
app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
webhook, err := app.webhook.Get(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Hide secret in the response.
if webhook.Secret != "" {
webhook.Secret = strings.Repeat(stringutil.PasswordDummy, 10)
}
return r.SendEnvelope(webhook)
}
// handleCreateWebhook creates a new webhook in the database.
func handleCreateWebhook(r *fastglue.Request) error {
var (
app = r.Context.(*App)
webhook = models.Webhook{}
)
if err := r.Decode(&webhook, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), err.Error(), envelope.InputError)
}
// Validate webhook fields
if err := validateWebhook(app, webhook); err != nil {
return r.SendEnvelope(err)
}
webhook, err := app.webhook.Create(webhook)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Clear secret before returning
webhook.Secret = strings.Repeat(stringutil.PasswordDummy, 10)
return r.SendEnvelope(webhook)
}
// handleUpdateWebhook updates an existing webhook in the database.
func handleUpdateWebhook(r *fastglue.Request) error {
var (
app = r.Context.(*App)
webhook = models.Webhook{}
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := r.Decode(&webhook, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), err.Error(), envelope.InputError)
}
// Validate webhook fields
if err := validateWebhook(app, webhook); err != nil {
return r.SendEnvelope(err)
}
// If secret is empty or contains dummy characters, fetch existing webhook and preserve the secret
if webhook.Secret == "" || strings.Contains(webhook.Secret, stringutil.PasswordDummy) {
existingWebhook, err := app.webhook.Get(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
webhook.Secret = existingWebhook.Secret
}
updatedWebhook, err := app.webhook.Update(id, webhook)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Clear secret before returning
updatedWebhook.Secret = strings.Repeat(stringutil.PasswordDummy, 10)
return r.SendEnvelope(updatedWebhook)
}
// handleDeleteWebhook deletes a webhook from the database.
func handleDeleteWebhook(r *fastglue.Request) error {
var (
app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := app.webhook.Delete(id); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// handleToggleWebhook toggles the active status of a webhook.
func handleToggleWebhook(r *fastglue.Request) error {
var (
app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
toggledWebhook, err := app.webhook.Toggle(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Clear secret before returning
toggledWebhook.Secret = strings.Repeat(stringutil.PasswordDummy, 10)
return r.SendEnvelope(toggledWebhook)
}
// handleTestWebhook sends a test payload to a webhook.
func handleTestWebhook(r *fastglue.Request) error {
var (
app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := app.webhook.SendTestWebhook(id); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// validateWebhook validates the webhook data.
func validateWebhook(app *App, webhook models.Webhook) error {
if webhook.Name == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil)
}
if webhook.URL == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`url`"), nil)
}
if len(webhook.Events) == 0 {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`events`"), nil)
}
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

@@ -107,10 +107,29 @@ worker_count = 10
# How often to run automatic conversation assignment
autoassign_interval = "5m"
[webhook]
# Number of webhook delivery workers
workers = 5
# Maximum number of webhook deliveries that can be queued
queue_size = 10000
# HTTP timeout for webhook requests
timeout = "15s"
[conversation]
# 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,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,36 +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
- 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()
@@ -212,7 +212,6 @@ const deleteView = async (view) => {
})
} catch (err) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(err).message
})

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

@@ -7,15 +7,15 @@ const http = axios.create({
})
function getCSRFToken () {
const name = 'csrf_token=';
const cookies = document.cookie.split(';');
const name = 'csrf_token='
const cookies = document.cookie.split(';')
for (let i = 0; i < cookies.length; i++) {
let c = cookies[i].trim();
let c = cookies[i].trim()
if (c.indexOf(name) === 0) {
return c.substring(name.length, c.length);
return c.substring(name.length, c.length)
}
}
return '';
return ''
}
// Request interceptor.
@@ -27,15 +27,20 @@ http.interceptors.request.use((request) => {
// Set content type for POST/PUT requests if the content type is not set.
if ((request.method === 'post' || request.method === 'put') && !request.headers['Content-Type']) {
request.headers['Content-Type'] = 'application/x-www-form-urlencoded'
request.headers['Content-Type'] = 'application/json'
}
if (request.headers['Content-Type'] === 'application/x-www-form-urlencoded') {
request.data = qs.stringify(request.data)
}
return request
})
const getCustomAttributes = (appliesTo) => http.get('/api/v1/custom-attributes', {
params: { applies_to: appliesTo }
})
const getCustomAttributes = (appliesTo) =>
http.get('/api/v1/custom-attributes', {
params: { applies_to: appliesTo }
})
const createCustomAttribute = (data) =>
http.post('/api/v1/custom-attributes', data, {
headers: {
@@ -54,7 +59,8 @@ const searchConversations = (params) => http.get('/api/v1/conversations/search',
const searchMessages = (params) => http.get('/api/v1/messages/search', { params })
const searchContacts = (params) => http.get('/api/v1/contacts/search', { params })
const getEmailNotificationSettings = () => http.get('/api/v1/settings/notifications/email')
const updateEmailNotificationSettings = (data) => http.put('/api/v1/settings/notifications/email', data)
const updateEmailNotificationSettings = (data) =>
http.put('/api/v1/settings/notifications/email', data)
const getPriorities = () => http.get('/api/v1/priorities')
const getStatuses = () => http.get('/api/v1/statuses')
const createStatus = (data) => http.post('/api/v1/statuses', data)
@@ -81,11 +87,12 @@ const updateTemplate = (id, data) =>
const getAllBusinessHours = () => http.get('/api/v1/business-hours')
const getBusinessHours = (id) => http.get(`/api/v1/business-hours/${id}`)
const createBusinessHours = (data) => http.post('/api/v1/business-hours', data, {
headers: {
'Content-Type': 'application/json'
}
})
const createBusinessHours = (data) =>
http.post('/api/v1/business-hours', data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateBusinessHours = (id, data) =>
http.put(`/api/v1/business-hours/${id}`, data, {
headers: {
@@ -96,16 +103,18 @@ const deleteBusinessHours = (id) => http.delete(`/api/v1/business-hours/${id}`)
const getAllSLAs = () => http.get('/api/v1/sla')
const getSLA = (id) => http.get(`/api/v1/sla/${id}`)
const createSLA = (data) => http.post('/api/v1/sla', data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateSLA = (id, data) => http.put(`/api/v1/sla/${id}`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const createSLA = (data) =>
http.post('/api/v1/sla', data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateSLA = (id, data) =>
http.put(`/api/v1/sla/${id}`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const deleteSLA = (id) => http.delete(`/api/v1/sla/${id}`)
const createOIDC = (data) =>
http.post('/api/v1/oidc', data, {
@@ -113,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) =>
@@ -130,7 +139,11 @@ const updateSettings = (key, data) =>
}
})
const getSettings = (key) => http.get(`/api/v1/settings/${key}`)
const login = (data) => http.post(`/api/v1/login`, data)
const login = (data) => http.post(`/api/v1/auth/login`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const getAutomationRules = (type) =>
http.get(`/api/v1/automations/rules`, {
params: { type: type }
@@ -156,7 +169,12 @@ const updateAutomationRuleWeights = (data) =>
'Content-Type': 'application/json'
}
})
const updateAutomationRulesExecutionMode = (data) => http.put(`/api/v1/automations/rules/execution-mode`, data)
const updateAutomationRulesExecutionMode = (data) =>
http.put(`/api/v1/automations/rules/execution-mode`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const getRoles = () => http.get('/api/v1/roles')
const getRole = (id) => http.get(`/api/v1/roles/${id}`)
const createRole = (data) =>
@@ -174,16 +192,29 @@ const updateRole = (id, data) =>
const deleteRole = (id) => http.delete(`/api/v1/roles/${id}`)
const getContacts = (params) => http.get('/api/v1/contacts', { params })
const getContact = (id) => http.get(`/api/v1/contacts/${id}`)
const updateContact = (id, data) => http.put(`/api/v1/contacts/${id}`, data, {
const updateContact = (id, data) =>
http.put(`/api/v1/contacts/${id}`, data, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
const blockContact = (id, data) => http.put(`/api/v1/contacts/${id}/block`, data, {
headers: {
'Content-Type': 'multipart/form-data'
'Content-Type': 'application/json'
}
})
const blockContact = (id, data) => http.put(`/api/v1/contacts/${id}/block`, data)
const getTeam = (id) => http.get(`/api/v1/teams/${id}`)
const getTeams = () => http.get('/api/v1/teams')
const updateTeam = (id, data) => http.put(`/api/v1/teams/${id}`, data)
const createTeam = (data) => http.post('/api/v1/teams', data)
const updateTeam = (id, data) => http.put(`/api/v1/teams/${id}`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const createTeam = (data) => http.post('/api/v1/teams', data, {
headers: {
'Content-Type': 'application/json'
}
})
const getTeamsCompact = () => http.get('/api/v1/teams/compact')
const deleteTeam = (id) => http.delete(`/api/v1/teams/${id}`)
const updateUser = (id, data) =>
@@ -204,9 +235,21 @@ const getUser = (id) => http.get(`/api/v1/agents/${id}`)
const deleteUserAvatar = () => http.delete('/api/v1/agents/me/avatar')
const getCurrentUser = () => http.get('/api/v1/agents/me')
const getCurrentUserTeams = () => http.get('/api/v1/agents/me/teams')
const updateCurrentUserAvailability = (data) => http.put('/api/v1/agents/me/availability', data)
const resetPassword = (data) => http.post('/api/v1/agents/reset-password', data)
const setPassword = (data) => http.post('/api/v1/agents/set-password', data)
const updateCurrentUserAvailability = (data) => http.put('/api/v1/agents/me/availability', data, {
headers: {
'Content-Type': 'application/json'
}
})
const resetPassword = (data) => http.post('/api/v1/agents/reset-password', data, {
headers: {
'Content-Type': 'application/json'
}
})
const setPassword = (data) => http.post('/api/v1/agents/set-password', data, {
headers: {
'Content-Type': 'application/json'
}
})
const deleteUser = (id) => http.delete(`/api/v1/agents/${id}`)
const createUser = (data) =>
http.post('/api/v1/agents', data, {
@@ -215,32 +258,56 @@ const createUser = (data) =>
}
})
const getTags = () => http.get('/api/v1/tags')
const upsertTags = (uuid, data) => http.post(`/api/v1/conversations/${uuid}/tags`, data)
const updateAssignee = (uuid, assignee_type, data) => http.put(`/api/v1/conversations/${uuid}/assignee/${assignee_type}`, data)
const removeAssignee = (uuid, assignee_type) => http.put(`/api/v1/conversations/${uuid}/assignee/${assignee_type}/remove`)
const updateContactCustomAttribute = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/contacts/custom-attributes`, data,
{
headers: {
'Content-Type': 'application/json'
}
})
const updateConversationCustomAttribute = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/custom-attributes`, data,
{
headers: {
'Content-Type': 'application/json'
}
})
const createConversation = (data) => http.post('/api/v1/conversations', data, {
const upsertTags = (uuid, data) => http.post(`/api/v1/conversations/${uuid}/tags`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateConversationStatus = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/status`, data)
const updateConversationPriority = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/priority`, data)
const updateAssignee = (uuid, assignee_type, data) =>
http.put(`/api/v1/conversations/${uuid}/assignee/${assignee_type}`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const removeAssignee = (uuid, assignee_type) =>
http.put(`/api/v1/conversations/${uuid}/assignee/${assignee_type}/remove`)
const updateContactCustomAttribute = (uuid, data) =>
http.put(`/api/v1/conversations/${uuid}/contacts/custom-attributes`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateConversationCustomAttribute = (uuid, data) =>
http.put(`/api/v1/conversations/${uuid}/custom-attributes`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const createConversation = (data) =>
http.post('/api/v1/conversations', data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateConversationStatus = (uuid, data) =>
http.put(`/api/v1/conversations/${uuid}/status`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateConversationPriority = (uuid, data) =>
http.put(`/api/v1/conversations/${uuid}/priority`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateAssigneeLastSeen = (uuid) => http.put(`/api/v1/conversations/${uuid}/last-seen`)
const getConversationMessage = (cuuid, uuid) => http.get(`/api/v1/conversations/${cuuid}/messages/${uuid}`)
const retryMessage = (cuuid, uuid) => http.put(`/api/v1/conversations/${cuuid}/messages/${uuid}/retry`)
const getConversationMessages = (uuid, params) => http.get(`/api/v1/conversations/${uuid}/messages`, { params })
const getConversationMessage = (cuuid, uuid) =>
http.get(`/api/v1/conversations/${cuuid}/messages/${uuid}`)
const retryMessage = (cuuid, uuid) =>
http.put(`/api/v1/conversations/${cuuid}/messages/${uuid}/retry`)
const getConversationMessages = (uuid, params) =>
http.get(`/api/v1/conversations/${uuid}/messages`, { params })
const sendMessage = (uuid, data) =>
http.post(`/api/v1/conversations/${uuid}/messages`, data, {
headers: {
@@ -251,28 +318,33 @@ const getConversation = (uuid) => http.get(`/api/v1/conversations/${uuid}`)
const getConversationParticipants = (uuid) => http.get(`/api/v1/conversations/${uuid}/participants`)
const getAllMacros = () => http.get('/api/v1/macros')
const getMacro = (id) => http.get(`/api/v1/macros/${id}`)
const createMacro = (data) => http.post('/api/v1/macros', data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateMacro = (id, data) => http.put(`/api/v1/macros/${id}`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const createMacro = (data) =>
http.post('/api/v1/macros', data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateMacro = (id, data) =>
http.put(`/api/v1/macros/${id}`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const deleteMacro = (id) => http.delete(`/api/v1/macros/${id}`)
const applyMacro = (uuid, id, data) => http.post(`/api/v1/conversations/${uuid}/macros/${id}/apply`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const applyMacro = (uuid, id, data) =>
http.post(`/api/v1/conversations/${uuid}/macros/${id}/apply`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const getTeamUnassignedConversations = (teamID, params) =>
http.get(`/api/v1/teams/${teamID}/conversations/unassigned`, { params })
const getAssignedConversations = (params) => http.get('/api/v1/conversations/assigned', { params })
const getUnassignedConversations = (params) => http.get('/api/v1/conversations/unassigned', { params })
const getUnassignedConversations = (params) =>
http.get('/api/v1/conversations/unassigned', { params })
const getAllConversations = (params) => http.get('/api/v1/conversations/all', { params })
const getViewConversations = (id, params) => http.get(`/api/v1/views/${id}/conversations`, { params })
const getViewConversations = (id, params) =>
http.get(`/api/v1/views/${id}/conversations`, { params })
const uploadMedia = (data) =>
http.post('/api/v1/media', data, {
headers: {
@@ -314,12 +386,50 @@ const updateView = (id, data) =>
})
const deleteView = (id) => http.delete(`/api/v1/views/me/${id}`)
const getAiPrompts = () => http.get('/api/v1/ai/prompts')
const aiCompletion = (data) => http.post('/api/v1/ai/completion', data)
const updateAIProvider = (data) => http.put('/api/v1/ai/provider', data)
const aiCompletion = (data) => http.post('/api/v1/ai/completion', data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateAIProvider = (data) => http.put('/api/v1/ai/provider', data, {
headers: {
'Content-Type': 'application/json'
}
})
const getContactNotes = (id) => http.get(`/api/v1/contacts/${id}/notes`)
const createContactNote = (id, data) => http.post(`/api/v1/contacts/${id}/notes`, data)
const createContactNote = (id, data) => http.post(`/api/v1/contacts/${id}/notes`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const deleteContactNote = (id, noteId) => http.delete(`/api/v1/contacts/${id}/notes/${noteId}`)
const getActivityLogs = (params) => http.get('/api/v1/activity-logs', { params })
const getWebhooks = () => http.get('/api/v1/webhooks')
const getWebhook = (id) => http.get(`/api/v1/webhooks/${id}`)
const createWebhook = (data) =>
http.post('/api/v1/webhooks', data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateWebhook = (id, data) =>
http.put(`/api/v1/webhooks/${id}`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const deleteWebhook = (id) => http.delete(`/api/v1/webhooks/${id}`)
const toggleWebhook = (id) => http.put(`/api/v1/webhooks/${id}/toggle`)
const testWebhook = (id) => http.post(`/api/v1/webhooks/${id}/test`)
const generateAPIKey = (id) =>
http.post(`/api/v1/agents/${id}/api-key`, {}, {
headers: {
'Content-Type': 'application/json'
}
})
const revokeAPIKey = (id) => http.delete(`/api/v1/agents/${id}/api-key`)
export default {
login,
@@ -404,7 +514,7 @@ export default {
updateSettings,
createOIDC,
getAllOIDC,
getAllEnabledOIDC,
getConfig,
getOIDC,
updateOIDC,
deleteOIDC,
@@ -448,5 +558,14 @@ export default {
getContactNotes,
createContactNote,
deleteContactNote,
getActivityLogs
getActivityLogs,
getWebhooks,
getWebhook,
createWebhook,
updateWebhook,
deleteWebhook,
toggleWebhook,
testWebhook,
generateAPIKey,
revokeAPIKey
}

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

@@ -0,0 +1,73 @@
<template>
<Button :variant="variant" :size="size" type="button" @click="handleCopy" :class="buttonClass">
<Copy v-if="!copied" class="w-4 h-4" />
<Check v-else class="w-4 h-4 text-green-500" />
<span v-if="showText">
{{ copied ? copiedText : copyText }}
</span>
</Button>
</template>
<script setup>
import { ref, computed } from 'vue'
import { Button } from '@shared-ui/components/ui/button'
import { Copy, Check } from 'lucide-vue-next'
import { useI18n } from 'vue-i18n'
const props = defineProps({
textToCopy: {
type: String,
required: true
},
variant: {
type: String,
default: 'secondary'
},
size: {
type: String,
default: 'sm'
},
showText: {
type: Boolean,
default: true
},
copyText: {
type: String,
default: null
},
copiedText: {
type: String,
default: null
},
resetDelay: {
type: Number,
default: 2000
},
class: {
type: String,
default: ''
}
})
const { t } = useI18n()
const copied = ref(false)
const buttonClass = computed(() => props.class)
const copyText = computed(() => props.copyText || t('globals.terms.copy'))
const copiedText = computed(() => props.copiedText || t('globals.terms.copied'))
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(props.textToCopy)
copied.value = true
if (props.resetDelay > 0) {
setTimeout(() => {
copied.value = false
}, props.resetDelay)
}
} catch (err) {
console.error('Failed to copy:', err)
}
}
</script>

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

@@ -0,0 +1,71 @@
<template>
<div ref="codeEditor" @click="editorView?.focus()" :class="readOnly ? 'w-full border rounded-md' : 'w-full h-[28rem] border rounded-md'" />
</template>
<script setup>
import { ref, onMounted, watch, nextTick, useTemplateRef } from 'vue'
import { EditorView, basicSetup } from 'codemirror'
import { html } from '@codemirror/lang-html'
import { javascript } from '@codemirror/lang-javascript'
import { oneDark } from '@codemirror/theme-one-dark'
import { useColorMode } from '@vueuse/core'
const props = defineProps({
modelValue: { type: String, default: '' },
language: { type: String, default: 'html' },
disabled: Boolean,
readOnly: Boolean
})
const emit = defineEmits(['update:modelValue'])
const data = ref('')
let editorView = null
const codeEditor = useTemplateRef('codeEditor')
const initCodeEditor = (body) => {
const isDark = useColorMode().value === 'dark'
const langExtension = props.language === 'javascript' ? javascript() : html()
const isEditable = !props.disabled && !props.readOnly
editorView = new EditorView({
doc: body,
extensions: [
basicSetup,
langExtension,
...(isDark ? [oneDark] : []),
EditorView.editable.of(isEditable),
EditorView.theme({
'&': { height: props.readOnly ? 'auto' : '100%' },
'.cm-editor': { height: props.readOnly ? 'auto' : '100%' },
'.cm-scroller': { overflow: 'auto' }
}),
EditorView.updateListener.of((update) => {
if (!update.docChanged || props.readOnly) return
const v = update.state.doc.toString()
emit('update:modelValue', v)
data.value = v
})
],
parent: codeEditor.value
})
if (!props.readOnly) {
nextTick(() => {
editorView?.focus()
})
}
}
onMounted(() => {
initCodeEditor(props.modelValue || '')
})
watch(() => props.modelValue, (newVal) => {
if (newVal !== data.value) {
editorView?.dispatch({
changes: { from: 0, to: editorView.state.doc.length, insert: newVal }
})
}
})
</script>

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'
import { RouterLink, useRoute } from 'vue-router'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
} from '../../constants/navigation'
import { RouterLink, useRoute, useRouter } from 'vue-router'
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,20 +37,33 @@ 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 { useUserStore } from '../../stores/user'
import { useConversationStore } from '../../stores/conversation'
defineProps({
userTeams: { type: Array, default: () => [] },
userViews: { type: Array, default: () => [] }
})
const userStore = useUserStore()
const conversationStore = useConversationStore()
const settingsStore = useAppSettingsStore()
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const emit = defineEmits(['createView', 'editView', 'deleteView', 'createConversation'])
@@ -70,8 +83,69 @@ 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
const navigateToInbox = (type) => {
if (conversationStore.hasConversationOpen && conversationStore.conversation.data?.uuid) {
router.push({
name: 'inbox-conversation',
params: {
type,
uuid: conversationStore.conversation.data.uuid
}
})
} else {
router.push({
name: 'inbox',
params: { type }
})
}
}
const navigateToTeamInbox = (teamID) => {
if (conversationStore.hasConversationOpen && conversationStore.conversation.data?.uuid) {
router.push({
name: 'team-inbox-conversation',
params: {
teamID,
uuid: conversationStore.conversation.data.uuid
}
})
} else {
router.push({
name: 'team-inbox',
params: { teamID }
})
}
}
const navigateToViewInbox = (viewID) => {
if (conversationStore.hasConversationOpen && conversationStore.conversation.data?.uuid) {
router.push({
name: 'view-inbox-conversation',
params: {
viewID,
uuid: conversationStore.conversation.data.uuid
}
})
} else {
router.push({
name: 'view-inbox',
params: { viewID }
})
}
}
const filteredAdminNavItems = computed(() => filterNavItems(adminNavItems, userStore.can))
@@ -102,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>
@@ -223,7 +304,7 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
>
<CollapsibleTrigger as-child>
<SidebarMenuButton :isActive="isActiveParent(item.href)">
<span>{{ t(item.titleKey) }}</span>
<span>{{ t(item.titleKey, item.isTitleKeyPlural === true ? 2 : 1) }}</span>
<ChevronRight
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
/>
@@ -322,32 +403,32 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild :isActive="isActiveParent('/inboxes/assigned')">
<router-link :to="{ name: 'inbox', params: { type: 'assigned' } }">
<a href="#" @click.prevent="navigateToInbox('assigned')">
<User />
<span>{{ t('globals.terms.myInbox') }}</span>
</router-link>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild :isActive="isActiveParent('/inboxes/unassigned')">
<router-link :to="{ name: 'inbox', params: { type: 'unassigned' } }">
<a href="#" @click.prevent="navigateToInbox('unassigned')">
<CircleDashed />
<span>
{{ t('globals.terms.unassigned') }}
</span>
</router-link>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild :isActive="isActiveParent('/inboxes/all')">
<router-link :to="{ name: 'inbox', params: { type: 'all' } }">
<a href="#" @click.prevent="navigateToInbox('all')">
<List />
<span>
{{ t('globals.messages.all') }}
</span>
</router-link>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
@@ -380,9 +461,9 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
:is-active="route.params.teamID == team.id"
asChild
>
<router-link :to="{ name: 'team-inbox', params: { teamID: team.id } }">
<a href="#" @click.prevent="navigateToTeamInbox(team.id)">
{{ team.emoji }}<span>{{ team.name }}</span>
</router-link>
</a>
</SidebarMenuButton>
</SidebarMenuSubItem>
</SidebarMenuSub>
@@ -417,30 +498,41 @@ 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
>
<router-link :to="{ name: 'view-inbox', params: { viewID: view.id } }">
<span class="break-words w-32 truncate">{{ view.name }}</span>
<SidebarMenuAction :showOnHover="true" class="mr-3">
<a href="#" @click.prevent="navigateToViewInbox(view.id)">
<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>
</DropdownMenu>
</SidebarMenuAction>
</router-link>
</a>
</SidebarMenuButton>
</SidebarMenuSubItem>
</SidebarMenuSub>
@@ -458,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

@@ -0,0 +1,160 @@
export const reportsNavItems = [
{
titleKey: 'globals.terms.overview',
href: '/reports/overview',
permission: 'reports:manage'
}
]
export const adminNavItems = [
{
titleKey: 'globals.terms.workspace',
children: [
{
titleKey: 'globals.terms.general',
href: '/admin/general',
permission: 'general_settings:manage'
},
{
titleKey: 'globals.terms.businessHour',
href: '/admin/business-hours',
permission: 'business_hours:manage'
},
{
titleKey: 'globals.terms.slaPolicy',
href: '/admin/sla',
permission: 'sla:manage'
}
]
},
{
titleKey: 'globals.terms.conversation',
children: [
{
titleKey: 'globals.terms.tag',
href: '/admin/conversations/tags',
permission: 'tags:manage'
},
{
titleKey: 'globals.terms.macro',
href: '/admin/conversations/macros',
permission: 'macros:manage'
},
{
titleKey: 'globals.terms.status',
href: '/admin/conversations/statuses',
permission: 'status:manage'
}
]
},
{
titleKey: 'globals.terms.inbox',
children: [
{
titleKey: 'globals.terms.inbox',
href: '/admin/inboxes',
permission: 'inboxes:manage'
}
]
},
{
titleKey: 'globals.terms.teammate',
children: [
{
titleKey: 'globals.terms.agent',
href: '/admin/teams/agents',
permission: 'users:manage'
},
{
titleKey: 'globals.terms.team',
href: '/admin/teams/teams',
permission: 'teams:manage'
},
{
titleKey: 'globals.terms.role',
href: '/admin/teams/roles',
permission: 'roles:manage'
},
{
titleKey: 'globals.terms.activityLog',
href: '/admin/teams/activity-log',
permission: 'activity_logs:manage'
}
]
},
{
titleKey: 'globals.terms.automation',
children: [
{
titleKey: 'globals.terms.automation',
href: '/admin/automations',
permission: 'automations:manage'
}
]
},
{
titleKey: 'globals.terms.customAttribute',
children: [
{
titleKey: 'globals.terms.customAttribute',
href: '/admin/custom-attributes',
permission: 'custom_attributes:manage'
}
]
},
{
titleKey: 'globals.terms.notification',
children: [
{
titleKey: 'globals.terms.email',
href: '/admin/notification',
permission: 'notification_settings:manage'
}
]
},
{
titleKey: 'globals.terms.template',
children: [
{
titleKey: 'globals.terms.template',
href: '/admin/templates',
permission: 'templates:manage'
}
]
},
{
titleKey: 'globals.terms.security',
children: [
{
titleKey: 'globals.terms.sso',
href: '/admin/sso',
permission: 'oidc:manage'
}
]
},
{
titleKey: 'globals.terms.integration',
isTitleKeyPlural: true,
children: [
{
titleKey: 'globals.terms.webhook',
href: '/admin/webhooks',
permission: 'webhooks:manage'
}
]
}
]
export const accountNavItems = [
{
titleKey: 'globals.terms.profile',
href: '/account/profile'
}
]
export const contactNavItems = [
{
titleKey: 'globals.terms.contact',
href: '/contacts'
}
]

View File

@@ -0,0 +1,43 @@
export const permissions = {
CONVERSATIONS_READ: 'conversations:read',
CONVERSATIONS_WRITE: 'conversations:write',
CONVERSATIONS_READ_ASSIGNED: 'conversations:read_assigned',
CONVERSATIONS_READ_ALL: 'conversations:read_all',
CONVERSATIONS_READ_UNASSIGNED: 'conversations:read_unassigned',
CONVERSATIONS_READ_TEAM_INBOX: 'conversations:read_team_inbox',
CONVERSATIONS_UPDATE_USER_ASSIGNEE: 'conversations:update_user_assignee',
CONVERSATIONS_UPDATE_TEAM_ASSIGNEE: 'conversations:update_team_assignee',
CONVERSATIONS_UPDATE_PRIORITY: 'conversations:update_priority',
CONVERSATIONS_UPDATE_STATUS: 'conversations:update_status',
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',
STATUS_MANAGE: 'status:manage',
OIDC_MANAGE: 'oidc:manage',
TAGS_MANAGE: 'tags:manage',
MACROS_MANAGE: 'macros:manage',
USERS_MANAGE: 'users:manage',
TEAMS_MANAGE: 'teams:manage',
AUTOMATIONS_MANAGE: 'automations:manage',
INBOXES_MANAGE: 'inboxes:manage',
ROLES_MANAGE: 'roles:manage',
TEMPLATES_MANAGE: 'templates:manage',
REPORTS_MANAGE: 'reports:manage',
BUSINESS_HOURS_MANAGE: 'business_hours:manage',
SLA_MANAGE: 'sla:manage',
AI_MANAGE: 'ai:manage',
CUSTOM_ATTRIBUTES_MANAGE: 'custom_attributes:manage',
CONTACTS_READ_ALL: 'contacts:read_all',
CONTACTS_READ: 'contacts:read',
CONTACTS_WRITE: 'contacts:write',
CONTACTS_BLOCK: 'contacts:block',
CONTACT_NOTES_READ: 'contact_notes:read',
CONTACT_NOTES_WRITE: 'contact_notes:write',
CONTACT_NOTES_DELETE: 'contact_notes:delete',
ACTIVITY_LOGS_MANAGE: 'activity_logs:manage',
WEBHOOKS_MANAGE: 'webhooks: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

@@ -52,6 +52,120 @@
</div>
</div>
<!-- API Key Management Section -->
<div class="bg-muted/30 box p-4 space-y-4" v-if="!isNewForm">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<p class="text-base font-semibold text-gray-900 dark:text-foreground">
{{ $t('globals.terms.apiKey', 2) }}
</p>
<p class="text-sm text-gray-500">
{{ $t('admin.agent.apiKey.description') }}
</p>
</div>
</div>
<!-- API Key Display -->
<div v-if="apiKeyData.api_key" class="space-y-3">
<div class="flex items-center justify-between p-3 bg-background border rounded-md">
<div class="flex items-center gap-3">
<Key class="w-4 h-4 text-gray-400" />
<div>
<p class="text-sm font-medium">{{ $t('globals.terms.apiKey') }}</p>
<p class="text-xs text-gray-500 font-mono">{{ apiKeyData.api_key }}</p>
</div>
</div>
<div class="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
@click="regenerateAPIKey"
:disabled="isAPIKeyLoading"
>
<RotateCcw class="w-4 h-4 mr-1" />
{{ $t('globals.messages.regenerate') }}
</Button>
<Button
type="button"
variant="destructive"
size="sm"
@click="revokeAPIKey"
:disabled="isAPIKeyLoading"
>
<Trash2 class="w-4 h-4 mr-1" />
{{ $t('globals.messages.revoke') }}
</Button>
</div>
</div>
<!-- Last Used Info -->
<div v-if="apiKeyLastUsedAt" class="text-xs text-gray-500">
{{ $t('globals.messages.lastUsed') }}:
{{ format(new Date(apiKeyLastUsedAt), 'PPpp') }}
</div>
</div>
<!-- No API Key State -->
<div v-else class="text-center py-6">
<Key class="w-8 h-8 text-gray-400 mx-auto mb-2" />
<p class="text-sm text-gray-500 mb-3">{{ $t('admin.agent.apiKey.noKey') }}</p>
<Button type="button" @click="generateAPIKey" :disabled="isAPIKeyLoading">
<Plus class="w-4 h-4 mr-1" />
{{ $t('globals.messages.generate', { name: $t('globals.terms.apiKey') }) }}
</Button>
</div>
</div>
<!-- API Key Display Dialog -->
<Dialog v-model:open="showAPIKeyDialog">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{{ $t('globals.messages.generated', { name: $t('globals.terms.apiKey') }) }}
</DialogTitle>
<DialogDescription> </DialogDescription>
</DialogHeader>
<div class="space-y-4">
<div>
<Label class="text-sm font-medium">{{ $t('globals.terms.apiKey') }}</Label>
<div class="flex items-center gap-2 mt-1">
<Input v-model="newAPIKeyData.api_key" readonly class="font-mono text-sm" />
<CopyButton
:text-to-copy="newAPIKeyData.api_key"
variant="outline"
size="sm"
:show-text="false"
/>
</div>
</div>
<div>
<Label class="text-sm font-medium">{{ $t('globals.terms.secret') }}</Label>
<div class="flex items-center gap-2 mt-1">
<Input v-model="newAPIKeyData.api_secret" readonly class="font-mono text-sm" />
<CopyButton
:text-to-copy="newAPIKeyData.api_secret"
variant="outline"
size="sm"
:show-text="false"
/>
</div>
</div>
<Alert>
<AlertTriangle class="h-4 w-4" />
<AlertTitle>{{ $t('globals.terms.warning') }}</AlertTitle>
<AlertDescription>
{{ $t('admin.agent.apiKey.warningMessage') }}
</AlertDescription>
</Alert>
</div>
<DialogFooter>
<Button @click="closeAPIKeyModal">{{ $t('globals.messages.close') }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- Form Fields -->
<FormField v-slot="{ field }" name="first_name">
<FormItem v-auto-animate>
@@ -186,17 +300,24 @@
<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 { Clock, LogIn } from 'lucide-vue-next'
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Badge } from '@shared-ui/components/ui/badge/index.js'
import { Clock, LogIn, Key, RotateCcw, Trash2, Plus, AlertTriangle } from 'lucide-vue-next'
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from '@shared-ui/components/ui/form/index.js'
import CopyButton from '@/components/button/CopyButton.vue'
import { Avatar, AvatarFallback, AvatarImage } from '@shared-ui/components/ui/avatar/index.js'
import {
Select,
SelectContent,
@@ -204,12 +325,23 @@ 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,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} 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.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: {
@@ -238,6 +370,19 @@ const props = defineProps({
const { t } = useI18n()
const teams = ref([])
const roles = ref([])
const emitter = useEmitter()
const apiKeyData = ref({
api_key: props.initialValues?.api_key || '',
api_secret: ''
})
const apiKeyLastUsedAt = ref(props.initialValues?.api_key_last_used_at || null)
const newAPIKeyData = ref({
api_key: '',
api_secret: ''
})
const showAPIKeyDialog = ref(false)
const isAPIKeyLoading = ref(false)
onMounted(async () => {
try {
@@ -245,7 +390,10 @@ onMounted(async () => {
teams.value = teamsResp.value.data.data
roles.value = rolesResp.value.data.data
} catch (err) {
console.log(err)
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: t('globals.messages.errorFetching')
})
}
})
@@ -273,7 +421,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)
})
@@ -284,6 +431,76 @@ const getInitials = (firstName, lastName) => {
return `${firstName.charAt(0).toUpperCase()}${lastName.charAt(0).toUpperCase()}`
}
const generateAPIKey = async () => {
if (!props.initialValues?.id) return
try {
isAPIKeyLoading.value = true
const response = await api.generateAPIKey(props.initialValues.id)
if (response.data) {
const responseData = response.data.data
newAPIKeyData.value = {
api_key: responseData.api_key,
api_secret: responseData.api_secret
}
apiKeyData.value.api_key = responseData.api_key
// Clear the last used timestamp since this is a new API key
apiKeyLastUsedAt.value = null
showAPIKeyDialog.value = true
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
description: t('globals.messages.generatedSuccessfully', {
name: t('globals.terms.apiKey')
})
})
}
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: t('globals.messages.errorGenerating', {
name: t('globals.terms.apiKey')
})
})
} finally {
isAPIKeyLoading.value = false
}
}
const regenerateAPIKey = async () => {
await generateAPIKey()
}
const revokeAPIKey = async () => {
if (!props.initialValues?.id) return
try {
isAPIKeyLoading.value = true
await api.revokeAPIKey(props.initialValues.id)
apiKeyData.value.api_key = ''
apiKeyData.value.api_secret = ''
apiKeyLastUsedAt.value = null
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
description: t('globals.messages.revokedSuccessfully', {
name: t('globals.terms.apiKey')
})
})
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: t('globals.messages.errorRevoking', {
name: t('globals.terms.apiKey')
})
})
} finally {
isAPIKeyLoading.value = false
}
}
const closeAPIKeyModal = () => {
showAPIKeyDialog.value = false
newAPIKeyData.value = { api_key: '', api_secret: '' }
}
watch(
() => props.initialValues,
(newValues) => {
@@ -302,6 +519,10 @@ watch(
'teams',
newValues.teams.map((team) => team.name)
)
// Update API key data
apiKeyData.value.api_key = newValues.api_key || ''
apiKeyLastUsedAt.value = newValues.api_key_last_used_at || null
}, 0)
}
},

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 '@shared-ui/utils/string'
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

@@ -62,7 +62,7 @@
:checked="!!selectedDays[day]"
@update:checked="handleDayToggle(day, $event)"
/>
<Label :for="day" class="font-medium text-gray-800">{{ day }}</Label>
<Label :for="day" class="font-medium">{{ day }}</Label>
</div>
<div class="flex space-x-2 items-center">
<div class="flex flex-col items-start">
@@ -156,7 +156,7 @@
</div>
<DialogFooter>
<Button :disabled="!holidayName || !holidayDate" @click="saveHoliday">
{{ t('globals.messages.saveChanges') }}
{{ t('globals.messages.add') }}
</Button>
</DialogFooter>
</DialogContent>
@@ -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: {
@@ -231,9 +231,16 @@ const { t } = useI18n()
const form = useForm({
validationSchema: toTypedSchema(createFormSchema(t)),
initialValues: props.initialValues
initialValues: {
is_always_open: true
}
})
// Sync form field with local state
const syncHoursToForm = () => {
form.setFieldValue('hours', { ...hours.value })
}
const saveHoliday = () => {
holidays.push({
name: holidayName.value,
@@ -252,21 +259,15 @@ const deleteHoliday = (item) => {
}
const handleDayToggle = (day, checked) => {
selectedDays.value = {
...selectedDays.value,
[day]: checked
selectedDays.value[day] = checked
if (checked) {
hours.value[day] = hours.value[day] || { open: '09:00', close: '17:00' }
} else {
delete hours.value[day]
}
if (checked && !hours.value[day]) {
hours.value[day] = { open: '09:00', close: '17:00' }
} else if (!checked) {
const newHours = { ...hours.value }
delete newHours[day]
hours.value = newHours
}
// Sync with form values
form.setFieldValue('hours', { ...hours.value })
syncHoursToForm()
}
const updateHours = (day, type, value) => {
@@ -274,50 +275,48 @@ const updateHours = (day, type, value) => {
hours.value[day] = { open: '09:00', close: '17:00' }
}
hours.value[day][type] = value
// Sync with form values
form.setFieldValue('hours', { ...hours.value })
syncHoursToForm()
}
const onSubmit = form.handleSubmit((values) => {
const businessHours =
values.is_always_open === true
? {}
: Object.keys(selectedDays.value)
.filter((day) => selectedDays.value[day])
.reduce((acc, day) => {
acc[day] = hours.value[day]
return acc
}, {})
const businessHours = values.is_always_open === true ? {} : { ...hours.value }
const finalValues = {
...values,
is_always_open: values.is_always_open,
hours: businessHours,
holidays: holidays
holidays: [...holidays]
}
props.submitForm(finalValues)
})
// Initialize state from props
const initializeFromValues = (values) => {
if (!values) return
// Reset state
hours.value = {}
selectedDays.value = {}
holidays.length = 0
// Set hours and selected days
if (values.hours && typeof values.hours === 'object') {
hours.value = { ...values.hours }
selectedDays.value = Object.keys(values.hours).reduce((acc, day) => {
acc[day] = true
return acc
}, {})
}
// Set holidays
if (values.holidays) {
holidays.push(...values.holidays)
}
// Update form
form.setValues(values)
syncHoursToForm()
}
// Watch for initial values
watch(
() => props.initialValues,
(newValues) => {
if (!newValues || Object.keys(newValues).length === 0) {
return
}
// Set business hours if provided
if (newValues.is_always_open === false) {
hours.value = newValues.hours || {}
selectedDays.value = Object.keys(hours.value).reduce((acc, day) => {
acc[day] = true
return acc
}, {})
}
// Set other form values
form.setValues(newValues)
holidays.length = 0
holidays.push(...(newValues.holidays || []))
},
{ deep: true }
)
watch(() => props.initialValues, initializeFromValues, { immediate: true, deep: true })
</script>

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

@@ -5,7 +5,7 @@ const timeRegex = /^([01]\d|2[0-3]):([0-5]\d)$/
export const createFormSchema = (t) => z.object({
name: z.string().min(1, t('globals.messages.required')),
description: z.string().min(1, t('globals.messages.required')),
is_always_open: z.boolean().default(true),
is_always_open: z.boolean(),
hours: z.record(
z.object({
open: z.string().regex(timeRegex, t('form.error.time.invalid')),

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