Compare commits

...

217 Commits

Author SHA1 Message Date
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
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
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
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
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
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
Abhinav Raut
803196985d fix: allow sending messages with just media files and no text in the editor 2025-06-08 16:42:35 +05:30
Abhinav Raut
ebf6a980e8 fix: update keyboard shortcuts label from cmd + k to ctrl + k 2025-06-08 16:35:59 +05:30
Abhinav Raut
813ef91964 fix: allow sending messages with ONLY attached media files and NO content in editor 2025-06-08 16:35:20 +05:30
Abhinav Raut
3b9fb7a08d fix: change 'id' prop requirement to optional with default value of null in CreateEditTemplate 2025-06-08 16:19:59 +05:30
Abhinav Raut
7fb86f140c fix: pass missing mediafiles prop to ReplyBoxContent for fullscreen and remove unused variables 2025-06-08 16:18:16 +05:30
Abhinav Raut
aa8d326fa1 fix: set default value of 'enabled' to true in EmailInboxForm 2025-06-08 16:17:32 +05:30
Abhinav Raut
ca9a0a5892 fix: update deletion confirmation messages for automation rules, SSO, status, tags, and templates 2025-06-08 16:17:26 +05:30
Abhinav Raut
73e2950174 fix: disable autoFocus on the Editor component in ActionBox 2025-06-08 16:14:32 +05:30
Abhinav Raut
e7b8e5c4bb fix(frontend): variable reference error 2025-06-08 15:41:24 +05:30
Abhinav Raut
582c906440 remove unncessary compiler macro imports 2025-06-08 15:25:04 +05:30
Abhinav Raut
f3881ee0aa fix(ci): update database and Redis host configurations in frontend CI workflow to localhost 2025-06-08 15:15:43 +05:30
Abhinav Raut
b557c2ca4b fix: update dialog title to use localized term for 'view' 2025-06-08 15:13:22 +05:30
Abhinav Raut
30884d3536 update(i18n): marathi translations 2025-06-08 15:13:22 +05:30
Abhinav Raut
bce0d1d12f Update README.md 2025-06-08 14:59:22 +05:30
Abhinav Raut
67a4f6a162 fix(docs): remove outdated instructions for editing config.toml in installation guide 2025-06-08 14:58:30 +05:30
Abhinav Raut
ec28ac8f3a Update README.md 2025-06-08 14:57:30 +05:30
Abhinav Raut
bc71fcfdc1 fix: add fallback for config typo in message outgoing scan interval key 2025-06-08 14:55:23 +05:30
Abhinav Raut
bc0bee8f6a update config.sample.toml with improved comments and configuration values 2025-06-08 14:55:15 +05:30
Abhinav Raut
499fc0dad1 fix: update SimpleTable component with loading state and skeleton rows
- remove existing loading table state from activity logs
2025-06-08 12:44:05 +05:30
Abhinav Raut
03b932c1c0 fix: handle empty inbox email address in fetchAndProcessMessages 2025-06-08 12:17:58 +05:30
Abhinav Raut
012de059e7 fix: remove conflicting constraints on NOT NULL columns in applied_slas and csat_responses tables, update constraints for csat_responses and applied_slas tables to cascade deletes instead 2025-06-08 11:50:22 +05:30
Abhinav Raut
6357faf6c8 fix csat page remove unncessary text and reference number
- fix form validations
2025-06-08 11:46:12 +05:30
Abhinav Raut
f7a12cffd3 fix: agent password regex validation
- adds vitest dev dep
- adds test cases for the agent form
- replace form loader svg with css spinner
2025-06-08 11:26:26 +05:30
Abhinav Raut
6487bf9a0a fix: validate and normalize email input in agent creation and update 2025-06-08 10:41:05 +05:30
Abhinav Raut
53d5715429 fix: implement loop prevention header in emails
#Ref 90
2025-06-08 02:22:36 +05:30
Abhinav Raut
b561e79440 fix: cypress test 2025-06-08 01:27:10 +05:30
Abhinav Raut
e567acbe59 fix(sql): overview report for days = 0 i.e. todays data 2025-06-08 01:24:01 +05:30
Abhinav Raut
57d0e90b5f refactors(i18n-keys): use correct keys in vue and go errors
- fix: invalidate agent cache on update / create
2025-06-08 00:59:15 +05:30
Abhinav Raut
5a0e3a8072 refactor: remove unncessary oidc test handler 2025-06-08 00:51:09 +05:30
Abhinav Raut
d95a5f40cf feat: add agent cache in user package
- use cached agent in all middlewares
- fix: race in casbin that gave permission denied error.
- stop loading permissions into casbin on every `Enforce` function call instead cache user permissions in authz package and when permissions change only the load permission as policies atomically.
- sort permissions in get-agents to make the permissions slice comparsion using slices.Equal work
2025-06-07 23:43:21 +05:30
Abhinav Raut
6981a0790d fix: update contacts route title to 'All contacts' 2025-06-07 22:38:43 +05:30
Abhinav Raut
55bc9bfc91 fix: safely access sender_id in handleNewMessage 2025-06-07 22:38:36 +05:30
Abhinav Raut
67db2e5ff2 fix: contact note text color in dark mode 2025-06-07 22:38:23 +05:30
Abhinav Raut
64304c2384 fix: reference err 2025-06-07 22:37:59 +05:30
Abhinav Raut
c5fe6aaadd open comobox on click of select tag component 2025-06-07 22:37:50 +05:30
Abhinav Raut
fea7eef658 dynamic translations in date filter component 2025-06-07 22:37:07 +05:30
Abhinav Raut
475e400810 fix: move isActive parent before watcher 2025-06-07 22:34:59 +05:30
Abhinav Raut
641ae0540e feat(sidebar): implement collapsible functionality for admin navigation based on active routes, this will make sure non active collsapsibles will close when another is opened 2025-06-07 22:32:41 +05:30
Abhinav Raut
dc6fede081 fix(inbox-layout): remove unnecessary keep-alive wrapper around router-view component preventing same conversation from being loaded again in different conversation list 2025-06-07 20:23:08 +05:30
Abhinav Raut
28dcd6cb2f fix(conversation-list): prevent error when setting unread count by checking item index 2025-06-07 19:58:45 +05:30
Abhinav Raut
ade833fb7b fix: waiting_since timestamp being set to null on a non-outgoing message.
- Set to null only after outgoing message is sent
- change imap log from debug to info
2025-06-07 19:57:28 +05:30
Abhinav Raut
5bcb0a2ad9 fix(dialog): add missing DialogDescription for a11y 2025-06-07 19:23:06 +05:30
Abhinav Raut
ad2f685fec add redis to dev setup pre-requisites 2025-06-07 19:00:20 +05:30
Abhinav Raut
26c7df538c refactor(i18n): consolidate duplicate keys into reusable globals
- Move form.field.* to globals.terms.*
- Replace select messages with globals.messages.select
- Use shared success/deletion confirmation messages
- Reduce ~50 duplicate keys
2025-06-07 18:58:17 +05:30
Abhinav Raut
625a08d0aa feat(conversation): add support for nested websocket property updates and broadcast changes for all contact and custom attributes 2025-06-07 18:58:17 +05:30
Abhinav Raut
bf1510b9c3 Merge pull request #101 from abhinavxd/dependabot/npm_and_yarn/frontend/vite-5.4.19
chore(deps-dev): bump vite from 5.4.18 to 5.4.19 in /frontend
2025-06-06 11:09:24 +05:30
dependabot[bot]
bae896d38d chore(deps-dev): bump vite from 5.4.18 to 5.4.19 in /frontend
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.18 to 5.4.19.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.19/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.19/packages/vite)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-05 22:46:23 +00:00
Abhinav Raut
37b7c05b30 update hero image 2025-06-06 04:06:20 +05:30
Abhinav Raut
eb05368f18 Update README.md 2025-06-06 04:03:13 +05:30
Abhinav Raut
7ef510894b feat(docs): add hero image and improve documentation formatting 2025-06-06 04:03:03 +05:30
Abhinav Raut
69268a3a84 Update README.md 2025-06-06 03:39:43 +05:30
Abhinav Raut
fcd3462d25 Update README.md 2025-06-06 03:39:17 +05:30
Abhinav Raut
fbf502451a fix(CommandBox): reorder CommandItems move macro on top 2025-06-06 03:23:43 +05:30
Abhinav Raut
dc909ceb4f feat(vite): Add manual chunking for optimized build output
refactor(router): Lazy load route components for improved performance
2025-06-06 03:20:32 +05:30
Abhinav Raut
cc1432b3e4 refactor(editor): Remove unncessary props and simplify code for tiptap editor, update all editors for the same 2025-06-06 03:02:11 +05:30
Abhinav Raut
d532a99771 feat: new package report, move exisiting report code from conversations pkg to report package
- new sla performance overview cards.
2025-06-06 02:07:19 +05:30
Abhinav Raut
50baa3f38e remove redundant comments for Manager struct across multiple files 2025-06-06 01:04:07 +05:30
Abhinav Raut
63a8f04408 fix: conversation list view filters, views do not need list status as views are already filtered. 2025-06-05 15:58:29 +05:30
Abhinav Raut
ea0b7d6d52 docs: update email templating docs with complete variable reference
- adds new `Author` template var and injects it into all templates
- make author fields empty for all automated system generated emails
2025-06-05 01:55:38 +05:30
Abhinav Raut
5d6897a960 fix: filter conversation list by status, this will immediately remove conversation from the list if status is different than the one applied to the list.
- remove redundant error title in toast notifications
2025-06-05 01:47:10 +05:30
Abhinav Raut
c4a95672fe update simples3 2025-06-04 17:12:58 +05:30
Abhinav Raut
2efd07b405 add error logs for casbin errors 2025-06-04 00:07:27 +05:30
Abhinav Raut
0b9cf38826 fix: update assignee last seen only if current conversation is open
- standardize timestamp function to uppercase NOW() in SQL queries
2025-06-04 00:07:13 +05:30
Abhinav Raut
b44c314299 update delete confirmation message for agent in alert dialog 2025-06-03 23:46:53 +05:30
Abhinav Raut
2e1188e443 Merge pull request #100 from abhinavxd/feat/allow-macro-in-new-conversations
Feat: Allow setting macro in new conversations along with attachments
2025-06-03 23:05:29 +05:30
Abhinav Raut
afeec39b59 make subject required, submit new conversation form on ctrl + enter 2025-06-03 23:03:06 +05:30
Abhinav Raut
fb2a08ec1a fix: SelectCombobox adjust item and selected templates for proper emoji rendering 2025-06-03 22:36:49 +05:30
Abhinav Raut
7f2df0082c fix: remove autofocus from text editor as create conversation form opens.
- Adds new prop autofocus on text editor
- rename ConversationTextEditor to TextEditor
2025-06-03 22:10:56 +05:30
Abhinav Raut
6c523ac447 fix: update labels and placeholders for user selection to agent in MacroForm 2025-06-03 05:19:13 +05:30
Abhinav Raut
02fc57c35a macro form fixes 2025-06-03 05:08:41 +05:30
Abhinav Raut
cd0a357695 fix: change order of macros selection to updated_at descending 2025-06-03 04:41:24 +05:30
Abhinav Raut
2dc751e602 fixes incorrect v-model binding 2025-06-03 04:29:28 +05:30
Abhinav Raut
8bc0cce993 fix: update default values for visible_when column in macros table 2025-06-03 04:08:29 +05:30
Abhinav Raut
f6e2fc1956 feat: allow sending attachments in new conversations
- replace existing combobox selects with common component selectcombobox.vue
2025-06-03 04:03:16 +05:30
Abhinav Raut
5fe5ac5882 fix: change order of macros selection to usage_count descending 2025-06-03 00:56:16 +05:30
Abhinav Raut
975577555d WIP: allow setting macro in new conversations along with attachments
- new composable useFileUpload.js
2025-06-02 03:56:04 +05:30
Abhinav Raut
f43acb77a1 use loader animation instead of dot loader in shadcn button 2025-05-31 23:46:08 +05:30
Abhinav Raut
331c84fa56 use dot loader to use tailwind animations 2025-05-31 23:40:42 +05:30
Abhinav Raut
9314efb9d9 refactor: clean up main.css move animation to tailwind config 2025-05-31 23:38:44 +05:30
Abhinav Raut
5c8481af97 feat: tooltips to icon side
refactor: remove unncessary extra i18n keys instead use reusable 'globals.terms.*' keys.
2025-05-31 20:11:47 +05:30
Abhinav Raut
d9bc4d1c0d fix: update conversation list item last message timestamp every 60 seconds 2025-05-31 18:54:08 +05:30
Abhinav Raut
087c8ad491 fix: incorrect label in macro form team select combobox 2025-05-31 18:35:48 +05:30
Abhinav Raut
65cac843cb fix: IMAP fetch blocking introduced by header fetching in this commit - 9a651702ce
Two separate fetches caused blocking when first fetch wasn't fully consumed.
Collect all envelope/header data first, then process to prevent deadlock.
2025-05-31 18:24:05 +05:30
Abhinav Raut
23b0481f24 update reference number format in conversation insert query, use - #number format instead of square bracket 2025-05-30 02:01:51 +05:30
Abhinav Raut
9a651702ce fix[imap]: skip auto reply email messages
Fixes #94
2025-05-30 02:00:18 +05:30
Abhinav Raut
a0203f882e fix: allow changing conversation status to resolved again & again as agent might change the snooze duration 2025-05-30 00:46:30 +05:30
Abhinav Raut
75425ca0dd Merge pull request #98 from abhinavxd/feat/dark-mode
feat: dark mode
2025-05-29 01:52:31 +05:30
Abhinav Raut
c2849fa63d fix views sidebar collapsible trigger 2025-05-29 01:48:29 +05:30
Abhinav Raut
b20c7845ac update sidebar icons for inbox navigation 2025-05-29 01:34:38 +05:30
Abhinav Raut
38a5b25b1f remove search icon from search header 2025-05-29 01:11:14 +05:30
Abhinav Raut
9dce155ebc fix: sidebar header spacing and ui improvements for search icon 2025-05-29 01:10:58 +05:30
Abhinav Raut
314341b40d fix: make both sections of macro preview 1. list and 2. preview scrollable separately.
- Update styles and colors for dark mode
2025-05-29 00:53:10 +05:30
Abhinav Raut
1f6e3322aa update sidebar background color and improve dark mode styles / colors
fix: email validation trigger in reply box
2025-05-29 00:52:16 +05:30
Abhinav Raut
102ba99b3c fix: toggle fullscreen state correctly in ReplyBox component 2025-05-29 00:39:14 +05:30
Abhinav Raut
8285575f1c update styles for convo list 2025-05-28 02:31:34 +05:30
Abhinav Raut
01d3b590a9 update sidebar foreground text color to improve contrast 2025-05-28 02:02:54 +05:30
Abhinav Raut
210e0de1ae feat: dark mode 2025-05-28 01:50:35 +05:30
Abhinav Raut
1f8fdf2ef6 Merge pull request #95 from abhinavxd/feat/sla-metric-next-response-time
Feature : Next response SLA metric
2025-05-27 03:19:52 +05:30
Abhinav Raut
696e4780ac refactor: reuse existing i18n keys for sla translations 2025-05-27 02:52:33 +05:30
Abhinav Raut
3998798e54 refactor: rename SQL query names and struct fields for clarity and consistency 2025-05-27 02:45:43 +05:30
Abhinav Raut
70b5da29e1 fix: change SLA deadline fields to use nullable types 2025-05-26 03:48:41 +05:30
Abhinav Raut
88ef5d26db fix: update sla timestamps to nullable types 2025-05-26 03:48:41 +05:30
Abhinav Raut
54bad59392 fix: getConversation to handle nullable UUID parameter 2025-05-26 03:48:41 +05:30
Abhinav Raut
506bb91e20 fix: make sla metric timestamps nullable 2025-05-26 03:48:41 +05:30
Abhinav Raut
d1478e1971 fix: clarify comment on SendNotification method regarding SLA linkage 2025-05-26 03:48:41 +05:30
Abhinav Raut
5583b472f7 fix: change debug logs to info level for scheduled SLA notifications 2025-05-26 03:48:41 +05:30
Abhinav Raut
b715483260 refactor(conversation): reduce DB I/O by using existing appliedSLAID from conversation
- Passes appliedSLAID directly to SLA logic instead of refetching
- Adds appliedSLAID field to conversation struct (already fetched in get-conversation query)
2025-05-26 03:48:41 +05:30
Abhinav Raut
8ce0464603 fix: simplify time_delay validation in SLA notification schema 2025-05-26 03:48:41 +05:30
Abhinav Raut
a84ed1ed32 Allow setting any value for SLA delay duration, replace select with input text
Validations to delay duration
2025-05-26 03:48:41 +05:30
Abhinav Raut
7426a09478 feat: allow setting metric per SLA notification, so admins can set SLA alert per metric or just set to all if they want a notification to be sent for all metrics
- Make sla time fields (first response, next response, resolution) optional, only 1 field is required.
2025-05-26 03:48:41 +05:30
Abhinav Raut
8ad2f078ac fix sql query 2025-05-26 03:48:41 +05:30
Abhinav Raut
9226063db3 fix: remove queries using conversation.applied_sla_id
as this column is removed
- fix sql query
2025-05-26 03:48:41 +05:30
Abhinav Raut
a9fd4fe2b6 fix: uise existing set next sla deadline sql query and remove duplicate query.
- remove previously added `applied_sla_id` column to conversations table as it was causing cyclic dep
2025-05-26 03:48:41 +05:30
Abhinav Raut
7e8c9962c3 Fixes for next response time sla metric 2025-05-26 03:48:41 +05:30
Abhinav Raut
cf20142e40 fix(sla-badge): emit SLA status on change so callers can react 2025-05-26 03:48:41 +05:30
Abhinav Raut
8654a04dcf fix: make sure sla badges re-render on timestamp changes, use a composite :key 2025-05-26 03:48:41 +05:30
Abhinav Raut
4c766d8ccb wip: next response metric for sla 2025-05-26 03:48:41 +05:30
Abhinav Raut
cb1ec7eb8e fix(availability-status): prevent 'away_and_reassigning' and away_manual agents from being set to 'offline' due to incorrect SQL 2025-05-24 19:42:41 +05:30
Abhinav Raut
a89c3dbe04 fix(agent-availability): skip activity log creation when agent returns online from inactivity as it can spam activity logs. 2025-05-20 00:53:28 +05:30
713 changed files with 24671 additions and 8538 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

@@ -53,6 +53,11 @@ jobs:
- name: Configure app
run: |
cp config.sample.toml config.toml
sed -i 's/host = "db"/host = "127.0.0.1"/' config.toml
sed -i 's/address = "redis:6379"/address = "localhost:6379"/' config.toml
- name: Run unit tests for frontend
run: cd frontend && pnpm test:run
- name: Install db schema and run tests
env:

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

@@ -5,18 +5,17 @@
Open source, self-hosted customer support desk. Single binary app.
![image](docs/docs/images/hero.png)
Visit [libredesk.io](https://libredesk.io) for more info. Check out the [**Live demo**](https://demo.libredesk.io/).
![image](https://github.com/user-attachments/assets/8e434a02-8b33-41c8-8433-3c98d1d5b834)
> **CAUTION:** This project is currently in **alpha**. Features and APIs may change and are not yet fully tested.
## Features
- **Multi Inbox**
Libredesk supports multiple inboxes, letting you manage conversations across teams effortlessly.
- **Multi Shared Inbox**
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**
@@ -31,14 +30,16 @@ Visit [libredesk.io](https://libredesk.io) for more info. Check out the [**Live
Distribute workload with auto assignment rules. Auto-assign conversations based on agent capacity or custom criteria.
- **SLA Management**
Set and track response time targets. Get notified when conversations are at risk of breaching SLA commitments.
- **Business Intelligence**
Connect your favorite BI tools like Metabase and create custom dashboards and reports with your support data—without lock-ins.
- **AI-Assisted Response Rewrite**
- **Custom attributes**
Create custom attributes for contacts or conversations such as the subscription plan or the date of their first purchase.
- **AI-Assist**
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.
Opens with a simple shortcut (CTRL+K) and lets you quickly perform actions on conversations.
And more checkout - [libredesk.io](https://libredesk.io)
@@ -57,8 +58,6 @@ 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
# Edit config.toml and find commented lines containing "docker compose". Replace the values in the lines below those comments with service names instead of IP addresses.
# Run the services in the background.
docker compose up -d
@@ -66,7 +65,7 @@ docker compose up -d
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.
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/)
@@ -86,6 +85,11 @@ __________________
## Developers
If you are interested in contributing, refer to the [developer setup](https://libredesk.io/docs/developer-setup/). The backend is written in Go and the frontend is Vue js 3 with Shadcn for UI components.
## Development Status
Libredesk is under active development.
Track roadmap and progress on the GitHub Project Board: [https://github.com/users/abhinavxd/projects/1](https://github.com/users/abhinavxd/projects/1)
## Translators
You can help translate Libredesk into your language on [Crowdin](https://crowdin.com/project/libredesk).

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)
}

1129
cmd/chat.go Normal file

File diff suppressed because it is too large Load Diff

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 (
@@ -185,12 +193,17 @@ 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 {
if err := app.user.CreateNote(contactID, auser.ID, req.Note); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
@@ -238,12 +251,18 @@ 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")
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))
}
if err := app.user.ToggleEnabled(contactID, models.UserTypeContact, req.Enabled); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)

View File

@@ -1,9 +1,7 @@
package main
import (
"encoding/json"
"strconv"
"strings"
"time"
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
@@ -11,13 +9,48 @@ import (
"github.com/abhinavxd/libredesk/internal/automation/models"
cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
"github.com/abhinavxd/libredesk/internal/envelope"
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"`
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"`
}
// handleGetAllConversations retrieves all conversations.
func handleGetAllConversations(r *fastglue.Request) error {
var (
@@ -291,13 +324,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, "")
@@ -305,17 +340,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)
}
@@ -326,12 +363,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)
@@ -342,28 +383,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)
}
@@ -380,22 +430,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)
@@ -420,19 +474,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.
@@ -452,18 +498,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)
@@ -531,36 +578,14 @@ 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.
app.conversation.BroadcastConversationUpdate(conversation.UUID, "contact.custom_attributes", attributes)
return r.SendEnvelope(true)
}
// handleDashboardCounts retrieves general dashboard counts for all users.
func handleDashboardCounts(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
counts, err := app.conversation.GetDashboardCounts(0, 0)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(counts)
}
// handleDashboardCharts retrieves general dashboard chart data.
func handleDashboardCharts(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
charts, err := app.conversation.GetDashboardChart(0, 0)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(charts)
}
// enforceConversationAccess fetches the conversation and checks if the user has access to it.
func enforceConversationAccess(app *App, uuid string, user umodels.User) (*cmodels.Conversation, error) {
conversation, err := app.conversation.GetConversation(0, uuid)
@@ -592,7 +617,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)
@@ -613,7 +638,7 @@ 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)
@@ -632,36 +657,32 @@ func filterCurrentConv(convs []cmodels.Conversation, uuid string) []cmodels.Conv
// handleCreateConversation creates a new conversation and sends a message to it.
func handleCreateConversation(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
inboxID = r.RequestCtx.PostArgs().GetUintOrZero("inbox_id")
assignedAgentID = r.RequestCtx.PostArgs().GetUintOrZero("agent_id")
assignedTeamID = r.RequestCtx.PostArgs().GetUintOrZero("team_id")
email = strings.TrimSpace(string(r.RequestCtx.PostArgs().Peek("contact_email")))
firstName = strings.TrimSpace(string(r.RequestCtx.PostArgs().Peek("first_name")))
lastName = strings.TrimSpace(string(r.RequestCtx.PostArgs().Peek("last_name")))
subject = strings.TrimSpace(string(r.RequestCtx.PostArgs().Peek("subject")))
content = strings.TrimSpace(string(r.RequestCtx.PostArgs().Peek("content")))
to = []string{email}
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
req = createConversationRequest{}
)
if err := r.Decode(&req, "json"); err != nil {
app.lo.Error("error decoding create conversation request", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
to := []string{req.Email}
// Validate required fields
if inboxID <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.fieldRequired", "name", "`inbox_id`"), nil, envelope.InputError)
if req.InboxID <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`inbox_id`"), nil, envelope.InputError)
}
if subject == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.fieldRequired", "name", "`subject`"), nil, envelope.InputError)
if req.Content == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`content`"), nil, envelope.InputError)
}
if content == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.fieldRequired", "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 email == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.fieldRequired", "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 firstName == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.fieldRequired", "name", "`first_name`"), nil, envelope.InputError)
}
if !stringutil.ValidEmail(email) {
if !stringutil.ValidEmail(req.Email) {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`contact_email`"), nil, envelope.InputError)
}
@@ -671,7 +692,7 @@ func handleCreateConversation(r *fastglue.Request) error {
}
// Check if inbox exists and is enabled.
inbox, err := app.inbox.GetDBRecord(inboxID)
inbox, err := app.inbox.GetDBRecord(req.InboxID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -681,11 +702,9 @@ func handleCreateConversation(r *fastglue.Request) error {
// Find or create contact.
contact := umodels.User{
Email: null.StringFrom(email),
SourceChannelID: null.StringFrom(email),
FirstName: firstName,
LastName: lastName,
InboxID: 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))
@@ -694,11 +713,10 @@ func handleCreateConversation(r *fastglue.Request) error {
// Create conversation
conversationID, conversationUUID, err := app.conversation.CreateConversation(
contact.ID,
contact.ContactChannelID,
inboxID,
req.InboxID,
"", /** last_message **/
time.Now(), /** last_message_at **/
subject,
req.Subject,
true, /** append reference number to subject **/
)
if err != nil {
@@ -706,8 +724,19 @@ func handleCreateConversation(r *fastglue.Request) error {
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.conversation}"), nil))
}
// Prepare attachments.
var media = make([]medModels.Media, 0, len(req.Attachments))
for _, id := range req.Attachments {
m, err := app.media.Get(id, "")
if err != nil {
app.lo.Error("error fetching media", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.media}"), nil, envelope.GeneralError)
}
media = append(media, m)
}
// Send reply to the created conversation.
if err := app.conversation.SendReply(nil /**media**/, inboxID, auser.ID /**sender_id**/, conversationUUID, content, to, nil /**cc**/, nil /**bcc**/, map[string]any{} /**meta**/); err != nil {
if _, err := app.conversation.SendReply(media, req.InboxID, auser.ID, contact.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)
@@ -716,14 +745,18 @@ func handleCreateConversation(r *fastglue.Request) error {
}
// Assign the conversation to the agent or team.
if assignedAgentID > 0 {
app.conversation.UpdateConversationUserAssignee(conversationUUID, assignedAgentID, user)
if req.AssignedAgentID > 0 {
app.conversation.UpdateConversationUserAssignee(conversationUUID, req.AssignedAgentID, user)
}
if assignedTeamID > 0 {
app.conversation.UpdateConversationTeamAssignee(conversationUUID, assignedTeamID, user)
if req.AssignedTeamID > 0 {
app.conversation.UpdateConversationTeamAssignee(conversationUUID, req.AssignedTeamID, user)
}
// Trigger webhook event for conversation created.
conversation, err := app.conversation.GetConversation(conversationID, "")
if err == nil {
app.webhook.TriggerEvent(wmodels.EventConversationCreated, conversation)
}
// Send the created conversation back to the client.
conversation, _ := app.conversation.GetConversation(conversationID, "")
return r.SendEnvelope(conversation)
}

View File

@@ -3,9 +3,16 @@ 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"`
}
// handleShowCSAT renders the CSAT page for a given csat.
func handleShowCSAT(r *fastglue.Request) error {
var (
@@ -42,7 +49,7 @@ func handleShowCSAT(r *fastglue.Request) error {
return app.tmpl.RenderWebPage(r.RequestCtx, "csat", map[string]interface{}{
"Data": map[string]interface{}{
"Title": "Rate your interaction with us",
"Title": "Rate your interaction with us",
"CSAT": map[string]interface{}{
"UUID": csat.UUID,
},
@@ -72,7 +79,7 @@ func handleUpdateCSATResponse(r *fastglue.Request) error {
})
}
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`",
@@ -103,3 +110,36 @@ func handleUpdateCSATResponse(r *fastglue.Request) error {
},
})
}
// 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)
@@ -37,7 +41,6 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
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.POST("/api/v1/oidc/test", perm(handleTestOIDC, "oidc:manage"))
g.GET("/api/v1/oidc/{id}", perm(handleGetOIDC, "oidc:manage"))
g.PUT("/api/v1/oidc/{id}", perm(handleUpdateOIDC, "oidc:manage"))
g.DELETE("/api/v1/oidc/{id}", perm(handleDeleteOIDC, "oidc:manage"))
@@ -111,6 +114,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))
@@ -158,9 +163,19 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
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/counts", perm(handleDashboardCounts, "reports:manage"))
g.GET("/api/v1/reports/overview/charts", perm(handleDashboardCharts, "reports:manage"))
g.GET("/api/v1/reports/overview/sla", perm(handleOverviewSLA, "reports:manage"))
g.GET("/api/v1/reports/overview/counts", perm(handleOverviewCounts, "reports:manage"))
g.GET("/api/v1/reports/overview/charts", perm(handleOverviewCharts, "reports:manage"))
// Templates.
g.GET("/api/v1/templates", perm(handleGetTemplates, "templates:manage"))
@@ -198,13 +213,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 +246,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 +288,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 +407,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"
@@ -47,11 +49,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 +62,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 +91,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 +100,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 +120,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 +129,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 +156,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 +171,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,8 @@ 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"
"github.com/abhinavxd/libredesk/internal/setting"
@@ -44,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"
@@ -130,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",
}
@@ -219,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)
@@ -448,6 +473,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)
@@ -569,11 +596,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)
}
@@ -823,6 +880,37 @@ func initActivityLog(db *sqlx.DB, i18n *i18n.I18n) *activitylog.Manager {
return m
}
// initReport inits report manager.
func initReport(db *sqlx.DB, i18n *i18n.I18n) *report.Manager {
lo := initLogger("report")
m, err := report.New(report.Opts{
DB: db,
Lo: lo,
I18n: i18n,
})
if err != nil {
log.Fatalf("error initializing report manager: %v", err)
}
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")
@@ -860,3 +948,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,12 +81,12 @@ func handleCreateMacro(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
err := app.macro.Create(macro.Name, macro.MessageContent, macro.UserID, macro.TeamID, macro.Visibility, macro.Actions)
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.
@@ -110,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.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.
@@ -275,13 +276,17 @@ func validateMacro(app *App, macro models.Macro) error {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil)
}
if len(macro.VisibleWhen) == 0 {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`visible_when`"), nil)
}
var act []autoModels.RuleAction
if err := json.Unmarshal(macro.Actions, &act); err != nil {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.macroAction}"), nil)
}
for _, a := range act {
if len(a.Value) == 0 {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.emptyActionValue", "name", a.Type), nil)
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", a.Type), nil)
}
}
return nil

View File

@@ -23,6 +23,7 @@ import (
customAttribute "github.com/abhinavxd/libredesk/internal/custom_attribute"
"github.com/abhinavxd/libredesk/internal/macro"
notifier "github.com/abhinavxd/libredesk/internal/notification"
"github.com/abhinavxd/libredesk/internal/report"
"github.com/abhinavxd/libredesk/internal/search"
"github.com/abhinavxd/libredesk/internal/sla"
"github.com/abhinavxd/libredesk/internal/view"
@@ -34,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"
@@ -52,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
@@ -90,6 +94,9 @@ type App struct {
activityLog *activitylog.Manager
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
@@ -157,13 +164,23 @@ func main() {
settings := initSettings(db)
loadSettings(settings)
// Fallback for config typo. Logs a warning but continues to work with the incorrect key.
// Uses 'message.message_outgoing_scan_interval' (correct key) as default key, falls back to the common typo.
msgOutgoingScanIntervalKey := "message.message_outgoing_scan_interval"
if ko.String(msgOutgoingScanIntervalKey) == "" {
if ko.String("message.message_outoing_scan_interval") != "" {
colorlog.Red("WARNING: typo in config key 'message.message_outoing_scan_interval' detected. Use 'message.message_outgoing_scan_interval' instead in your config.toml file. Support for this incorrect key will be removed in a future release.")
msgOutgoingScanIntervalKey = "message.message_outoing_scan_interval"
}
}
var (
autoAssignInterval = ko.MustDuration("autoassigner.autoassign_interval")
unsnoozeInterval = ko.MustDuration("conversation.unsnooze_interval")
automationWorkers = ko.MustInt("automation.worker_count")
messageOutgoingQWorkers = ko.MustDuration("message.outgoing_queue_workers")
messageIncomingQWorkers = ko.MustDuration("message.incoming_queue_workers")
messageOutgoingScanInterval = ko.MustDuration("message.message_outoing_scan_interval")
messageOutgoingScanInterval = ko.MustDuration(msgOutgoingScanIntervalKey)
slaEvaluationInterval = ko.MustDuration("sla.evaluation_interval")
lo = initLogger(appName)
rdb = initRedis()
@@ -179,21 +196,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)
@@ -224,12 +249,15 @@ func main() {
customAttribute: initCustomAttribute(db, i18n),
authz: initAuthz(i18n),
view: initView(db),
report: initReport(db, i18n),
csat: initCSAT(db, i18n),
search: initSearch(db, i18n),
role: initRole(db, i18n),
tag: initTag(db, i18n),
macro: initMacro(db, i18n),
ai: initAI(db, i18n),
webhook: webhook,
rateLimit: rateLimiter,
}
app.consts.Store(constants)
@@ -273,6 +301,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

@@ -4,7 +4,7 @@ import (
"strconv"
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
"github.com/abhinavxd/libredesk/internal/automation/models"
cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
"github.com/abhinavxd/libredesk/internal/envelope"
medModels "github.com/abhinavxd/libredesk/internal/media/models"
"github.com/valyala/fasthttp"
@@ -42,7 +42,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 +53,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 +91,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)
@@ -132,7 +135,6 @@ func handleSendMessage(r *fastglue.Request) error {
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
cuuid = r.RequestCtx.UserValue("cuuid").(string)
media = []medModels.Media{}
req = messageReq{}
)
@@ -152,7 +154,17 @@ 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.
var media = make([]medModels.Media, 0, len(req.Attachments))
for _, id := range req.Attachments {
m, err := app.media.Get(id, "")
if err != nil {
@@ -163,16 +175,16 @@ func handleSendMessage(r *fastglue.Request) error {
}
if req.Private {
if err := app.conversation.SendPrivateNote(media, user.ID, cuuid, req.Message); err != nil {
message, err := app.conversation.SendPrivateNote(media, user.ID, cuuid, req.Message)
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)
message, err := app.conversation.SendReply(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.GetAgent(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.GetAgent(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 user from DB.
user, err := app.user.GetAgent(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

@@ -50,18 +50,6 @@ func handleGetOIDC(r *fastglue.Request) error {
return r.SendEnvelope(o)
}
// handleTestOIDC tests an OIDC provider URL by doing a discovery on the provider URL.
func handleTestOIDC(r *fastglue.Request) error {
var (
app = r.Context.(*App)
providerURL = string(r.RequestCtx.PostArgs().Peek("provider_url"))
)
if err := app.auth.TestProvider(providerURL); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// handleCreateOIDC creates a new OIDC record.
func handleCreateOIDC(r *fastglue.Request) error {
var (
@@ -72,7 +60,13 @@ func handleCreateOIDC(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.GeneralError)
}
if err := app.oidc.Create(req); err != nil {
// Test OIDC provider URL by performing a discovery.
if err := app.auth.TestProvider(req.ProviderURL); err != nil {
return sendErrorEnvelope(r, err)
}
createdOIDC, err := app.oidc.Create(req)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -80,7 +74,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.
@@ -98,7 +96,13 @@ func handleUpdateOIDC(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.GeneralError)
}
if err = app.oidc.Update(id, req); err != nil {
// Test OIDC provider URL by performing a discovery.
if err := app.auth.TestProvider(req.ProviderURL); err != nil {
return sendErrorEnvelope(r, err)
}
updatedOIDC, err := app.oidc.Update(id, req)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -106,7 +110,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.

45
cmd/report.go Normal file
View File

@@ -0,0 +1,45 @@
package main
import (
"strconv"
"github.com/zerodha/fastglue"
)
// handleOverviewCounts retrieves general dashboard counts for all users.
func handleOverviewCounts(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
counts, err := app.report.GetOverViewCounts()
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(counts)
}
// handleOverviewCharts retrieves general dashboard chart data.
func handleOverviewCharts(r *fastglue.Request) error {
var (
app = r.Context.(*App)
days, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("days")))
)
charts, err := app.report.GetOverviewChart(days)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(charts)
}
// handleOverviewSLA retrieves SLA data for the dashboard.
func handleOverviewSLA(r *fastglue.Request) error {
var (
app = r.Context.(*App)
days, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("days")))
)
sla, err := app.report.GetOverviewSLA(days)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(sla)
}

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

@@ -29,7 +29,7 @@ func handleGetSLA(r *fastglue.Request) error {
)
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", "SLA `id`"), nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
sla, err := app.sla.Get(id)
@@ -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.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.
@@ -70,7 +71,7 @@ func handleUpdateSLA(r *fastglue.Request) error {
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", "SLA `id`"), nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := r.Decode(&sla, "json"); err != nil {
@@ -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.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("SLA updated successfully.")
return r.SendEnvelope(updatedSLA)
}
// handleDeleteSLA deletes the SLA with the given ID.
@@ -95,7 +97,7 @@ func handleDeleteSLA(r *fastglue.Request) error {
)
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", "SLA `id`"), nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err = app.sla.Delete(id); err != nil {
@@ -108,51 +110,79 @@ func handleDeleteSLA(r *fastglue.Request) error {
// validateSLA validates the SLA policy and returns an envelope.Error if any validation fails.
func validateSLA(app *App, sla *smodels.SLAPolicy) error {
if sla.Name == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "SLA `name`"), nil)
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil)
}
if sla.FirstResponseTime == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "SLA `first_response_time`"), nil)
}
if sla.ResolutionTime == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "SLA `resolution_time`"), nil)
if sla.FirstResponseTime.String == "" && sla.NextResponseTime.String == "" && sla.ResolutionTime.String == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "At least one of `first_response_time`, `next_response_time`, or `resolution_time` must be provided."), nil)
}
// Validate notifications if any
// Validate notifications if any.
for _, n := range sla.Notifications {
if n.Type == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "SLA notification `type`"), nil)
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`type`"), nil)
}
if n.TimeDelayType == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "SLA notification `time_delay_type`"), nil)
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`time_delay_type`"), nil)
}
if n.Metric == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`metric`"), nil)
}
if n.TimeDelayType != "immediately" {
if n.TimeDelay == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "SLA notification `time_delay`"), nil)
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`time_delay`"), nil)
}
// Validate time delay duration.
td, err := time.ParseDuration(n.TimeDelay)
if err != nil {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`time_delay`"), nil)
}
if td.Minutes() < 1 {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`time_delay`"), nil)
}
}
if len(n.Recipients) == 0 {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "SLA notification `recipients`"), nil)
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`recipients`"), nil)
}
}
// Validate time duration strings
frt, err := time.ParseDuration(sla.FirstResponseTime)
if err != nil {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`first_response_time`"), nil)
}
if frt.Minutes() < 1 {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`first_response_time`"), nil)
// Validate first response time duration string if not empty.
if sla.FirstResponseTime.String != "" {
frt, err := time.ParseDuration(sla.FirstResponseTime.String)
if err != nil {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`first_response_time`"), nil)
}
if frt.Minutes() < 1 {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`first_response_time`"), nil)
}
}
rt, err := time.ParseDuration(sla.ResolutionTime)
if err != nil {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`resolution_time`"), nil)
// Validate resolution time duration string if not empty.
if sla.ResolutionTime.String != "" {
rt, err := time.ParseDuration(sla.ResolutionTime.String)
if err != nil {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`resolution_time`"), nil)
}
if rt.Minutes() < 1 {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`resolution_time`"), nil)
}
// Compare with first response time if both are present.
if sla.FirstResponseTime.String != "" {
frt, _ := time.ParseDuration(sla.FirstResponseTime.String)
if frt > rt {
return envelope.NewError(envelope.InputError, app.i18n.T("sla.firstResponseTimeAfterResolution"), nil)
}
}
}
if rt.Minutes() < 1 {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`resolution_time`"), nil)
}
if frt > rt {
return envelope.NewError(envelope.InputError, app.i18n.T("sla.firstResponseTimeAfterResolution"), nil)
// Validate next response time duration string if not empty.
if sla.NextResponseTime.String != "" {
nrt, err := time.ParseDuration(sla.NextResponseTime.String)
if err != nil {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`next_response_time`"), nil)
}
if nrt.Minutes() < 1 {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`next_response_time`"), nil)
}
}
return nil

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,8 @@ 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.8.0", migrations.V0_8_0},
}
// upgrade upgrades the database to the current version by running SQL migration files

View File

@@ -26,6 +26,29 @@ const (
maxAvatarSizeMB = 2
)
// Request structs for user-related endpoints
// UpdateAvailabilityRequest represents the request to update user availability
type UpdateAvailabilityRequest struct {
Status string `json:"status"`
}
// ResetPasswordRequest represents the password reset request
type ResetPasswordRequest struct {
Email string `json:"email"`
}
// SetPasswordRequest represents the set password request
type SetPasswordRequest struct {
Token string `json:"token"`
Password string `json:"password"`
}
// AvailabilityRequest represents the request to update agent availability
type AvailabilityRequest struct {
Status string `json:"status"`
}
// handleGetAgents returns all agents.
func handleGetAgents(r *fastglue.Request) error {
var (
@@ -67,20 +90,37 @@ 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
)
// Update availability status.
if err := app.user.UpdateAvailability(auser.ID, status); err != nil {
// 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)
}
agent, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
// Create activity log.
if err := app.activityLog.UserAvailability(auser.ID, auser.Email, status, ip, "", 0); err != nil {
app.lo.Error("error creating activity log", "error", err)
// Same status?
if agent.AvailabilityStatus == availReq.Status {
return r.SendEnvelope(true)
}
// 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 && 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)
@@ -145,6 +185,11 @@ func handleCreateAgent(r *fastglue.Request) error {
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)
@@ -154,7 +199,6 @@ func handleCreateAgent(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`first_name`"), nil, envelope.InputError)
}
// Right now, only agents can be created.
if err := app.user.CreateAgent(&user); err != nil {
return sendErrorEnvelope(r, err)
}
@@ -203,9 +247,9 @@ func handleUpdateAgent(r *fastglue.Request) error {
user = models.User{}
auser = r.RequestCtx.UserValue("user").(amodels.User)
ip = realip.FromRequest(r.RequestCtx)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
if id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`id`"), nil, envelope.InputError)
}
@@ -216,6 +260,11 @@ func handleUpdateAgent(r *fastglue.Request) error {
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)
@@ -236,6 +285,9 @@ func handleUpdateAgent(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
// Invalidate authz cache.
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 {
@@ -328,19 +380,23 @@ 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.")
@@ -378,20 +434,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)
}
@@ -461,3 +519,61 @@ func uploadUserAvatar(r *fastglue.Request, user *models.User, files []*multipart
}
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)
}

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

@@ -1,80 +1,135 @@
# App.
[app]
# Log level: info, debug, warn, error, fatal
log_level = "debug"
# Environment: dev, prod.
# Setting to "dev" will enable color logging in terminal.
env = "dev"
# Whether to automatically check for application updates on start up, app updates are shown as a banner in the admin panel.
check_updates = true
# HTTP server.
[app.server]
# Address to bind the HTTP server to.
address = "0.0.0.0:9000"
# Unix socket path (leave empty to use TCP address instead)
socket = ""
# Do NOT disable secure cookies in production environment if you don't know
# exactly what you're doing!
# Do NOT disable secure cookies in production environment if you don't know exactly what you're doing!
disable_secure_cookies = false
# Request read and write timeouts.
read_timeout = "5s"
write_timeout = "5s"
max_body_size = 500000000
# Maximum request body size in bytes (100MB)
# If you are using proxy, you may need to configure them to allow larger request bodies.
max_body_size = 104857600
# Size of the read buffer for incoming requests
read_buffer_size = 4096
# Keepalive settings.
keepalive_timeout = "10s"
# File upload provider to use, either `fs` or `s3`.
[upload]
provider = "fs"
# Filesytem provider.
# Filesystem provider.
[upload.fs]
# Directory where uploaded files are stored, make sure this directory exists and is writable by the application.
upload_path = 'uploads'
# S3 provider.
[upload.s3]
# S3 endpoint URL (required only for non-AWS S3-compatible providers like MinIO).
# Leave empty to use default AWS endpoints.
url = ""
# AWS S3 credentials, keep empty to use attached IAM roles.
access_key = ""
secret_key = ""
# AWS region, e.g., "us-east-1", "eu-west-1", etc.
region = "ap-south-1"
bucket = "bucket"
# S3 bucket name where files will be stored.
bucket = "bucket-name"
# Optional prefix path within the S3 bucket where files will be stored.
# Example, if set to "uploads/media", files will be stored under that path.
# Useful for organizing files inside a shared bucket.
bucket_path = ""
expiry = "6h"
# S3 signed URL expiry duration (e.g., "30m", "1h")
expiry = "30m"
# Postgres.
[db]
# If using docker compose, use the service name as the host. e.g. db
host = "127.0.0.1"
# If running locally, use `localhost`.
host = "db"
# Database port, default is 5432.
port = 5432
# Update the following values with your database credentials.
user = "libredesk"
password = "libredesk"
database = "libredesk"
ssl_mode = "disable"
# Maximum number of open database connections
max_open = 30
# Maximum number of idle connections in the pool
max_idle = 30
# Maximum time a connection can be reused before being closed
max_lifetime = "300s"
# Redis.
[redis]
# If using docker compose, use the service name as the host. e.g. redis:6379
address = "127.0.0.1:6379"
# If running locally, use `localhost:6379`.
address = "redis:6379"
password = ""
db = 0
[message]
# Number of workers processing outgoing message queue
outgoing_queue_workers = 10
# Number of workers processing incoming message queue
incoming_queue_workers = 10
message_outoing_scan_interval = "50ms"
# How often to scan for outgoing messages to process, keep it low to process messages quickly.
message_outgoing_scan_interval = "50ms"
# Maximum number of messages that can be queued for incoming processing
incoming_queue_size = 5000
# Maximum number of messages that can be queued for outgoing processing
outgoing_queue_size = 5000
[notification]
# Number of concurrent notification workers
concurrency = 2
# Maximum number of notifications that can be queued
queue_size = 2000
[automation]
# Number of workers processing automation rules
worker_count = 10
[autoassigner]
# 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

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

View File

@@ -4,9 +4,10 @@ Libredesk is a monorepo with a Go backend and a Vue.js frontend. The frontend us
### Pre-requisites
- `go`
- `nodejs` (if you are working on the frontend) and `pnpm`
- Postgres database (>= 13)
- go
- nodejs (if you are working on the frontend) and `pnpm`
- redis
- postgres database (>= 13)
### First time setup

BIN
docs/docs/images/hero.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

View File

@@ -1,13 +1,17 @@
# Introduction
Libredesk is an open source, self-hosted customer support desk. Single binary app.
Libredesk is an open-source, self-hosted customer support desk — single binary app.
<div style="border: 1px solid #ccc; padding: 1px; border-radius:5px; box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.1); background-color: #fff;">
<a href="https://libredesk.io">
<img src="https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-HvmxvOkalQSLp4qVezdTXaCd3dB4Rm.png" alt="libredesk screenshot" style="display: block; margin: 0 auto;">
</a>
<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 a free and open source software licensed under AGPLv3. If you are interested in contributing, check out the [GitHub repository](https://github.com/abhinavxd/libredesk) and refer to the [developer setup](developer-setup.md). The backend is written in Go and the frontend is Vue js 3 with Shadcn for UI components.
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

@@ -27,8 +27,6 @@ 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
# Edit config.toml and find commented lines containing "docker compose". Replace the values in the lines below those comments with service names instead of IP addresses.
# Run the services in the background.
docker compose up -d

View File

@@ -1,6 +1,6 @@
# 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, and recipient objects.
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
@@ -8,36 +8,53 @@ If you want to customize the look of outgoing emails, you can do so in the Admin
### Conversation Variables
| Variable | Value |
| Variable | Value |
|---------------------------------|--------------------------------------------------------|
| {{ .Conversation.ReferenceNumber }} | The unique reference number of the conversation |
| {{ .Conversation.Subject }} | The subject of the conversation |
| {{ .Conversation.UUID }} | The unique identifier of the conversation |
| {{ .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 |
| 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 |
| {{ .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 |
| 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 }}
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.
Similarly, the `{{ .Recipient.FirstName }}` expression will dynamically insert the recipient's first name when the email is sent.

View File

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

222
docs/docs/webhooks.md Normal file
View File

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

View File

@@ -1,13 +1,11 @@
site_name: Libredesk Documentation
site_name: Libredesk Docs
theme:
name: material
language: en
font:
text: Source Sans Pro
code: Roboto Mono
weights:
- 400
- 700
weights: [400, 700]
direction: ltr
palette:
primary: white
@@ -16,9 +14,9 @@ theme:
- navigation.indexes
- navigation.sections
- content.code.copy
extra:
search:
language: en
extra:
search:
language: en
markdown_extensions:
- admonition
@@ -30,9 +28,11 @@ nav:
- Introduction: index.md
- Getting Started:
- Installation: installation.md
- Upgrade: upgrade.md
- Templating: templating.md
- SSO: sso.md
- Contributors:
- Developer setup: developer-setup.md
- Translations: translations.md
- Upgrade Guide: upgrade.md
- Email Templates: templating.md
- SSO Setup: sso.md
- Webhooks: webhooks.md
- API Getting Started: api-getting-started.md
- Contributions:
- Developer Setup: developer-setup.md
- Translate Libredesk: translations.md

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

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

View File

@@ -6,8 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&display=swap"
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&display=swap"
rel="stylesheet">
</head>

View File

@@ -1,5 +1,5 @@
<template>
<div class="flex w-full h-screen">
<div class="flex w-full h-screen text-foreground">
<!-- Icon sidebar always visible -->
<SidebarProvider style="--sidebar-width: 3rem" class="w-auto z-50">
<ShadcnSidebar collapsible="none" class="border-r">
@@ -8,38 +8,64 @@
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild :isActive="route.path.startsWith('/inboxes')">
<router-link :to="{ name: 'inboxes' }">
<Inbox />
</router-link>
</SidebarMenuButton>
<Tooltip>
<TooltipTrigger as-child>
<SidebarMenuButton asChild :isActive="route.path.startsWith('/inboxes')">
<router-link :to="{ name: 'inboxes' }">
<Inbox />
</router-link>
</SidebarMenuButton>
</TooltipTrigger>
<TooltipContent side="right">
<p>{{ t('globals.terms.inbox', 2) }}</p>
</TooltipContent>
</Tooltip>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
asChild
:isActive="route.path.startsWith('/contacts')"
v-if="userStore.can('contacts:read_all')"
>
<router-link :to="{ name: 'contacts' }">
<BookUser />
</router-link>
</SidebarMenuButton>
<SidebarMenuItem v-if="userStore.can('contacts:read_all')">
<Tooltip>
<TooltipTrigger as-child>
<SidebarMenuButton asChild :isActive="route.path.startsWith('/contacts')">
<router-link :to="{ name: 'contacts' }">
<BookUser />
</router-link>
</SidebarMenuButton>
</TooltipTrigger>
<TooltipContent side="right">
<p>{{ t('globals.terms.contact', 2) }}</p>
</TooltipContent>
</Tooltip>
</SidebarMenuItem>
<SidebarMenuItem v-if="userStore.hasReportTabPermissions">
<SidebarMenuButton asChild :isActive="route.path.startsWith('/reports')">
<router-link :to="{ name: 'reports' }">
<FileLineChart />
</router-link>
</SidebarMenuButton>
<Tooltip>
<TooltipTrigger as-child>
<SidebarMenuButton asChild :isActive="route.path.startsWith('/reports')">
<router-link :to="{ name: 'reports' }">
<FileLineChart />
</router-link>
</SidebarMenuButton>
</TooltipTrigger>
<TooltipContent side="right">
<p>{{ t('globals.terms.report', 2) }}</p>
</TooltipContent>
</Tooltip>
</SidebarMenuItem>
<SidebarMenuItem v-if="userStore.hasAdminTabPermissions">
<SidebarMenuButton asChild :isActive="route.path.startsWith('/admin')">
<router-link
:to="{ name: userStore.can('general_settings:manage') ? 'general' : 'admin' }"
>
<Shield />
</router-link>
</SidebarMenuButton>
<Tooltip>
<TooltipTrigger as-child>
<SidebarMenuButton asChild :isActive="route.path.startsWith('/admin')">
<router-link
:to="{
name: userStore.can('general_settings:manage') ? 'general' : 'admin'
}"
>
<Shield />
</router-link>
</SidebarMenuButton>
</TooltipTrigger>
<TooltipContent side="right">
<p>{{ t('globals.terms.admin') }}</p>
</TooltipContent>
</Tooltip>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
@@ -80,32 +106,32 @@
<Command />
<!-- Create conversation dialog -->
<CreateConversation v-model="openCreateConversationDialog" />
<CreateConversation v-model="openCreateConversationDialog" v-if="openCreateConversationDialog" />
</template>
<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 AppUpdate from '@main/components/update/AppUpdate.vue'
import api from './api'
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'
@@ -121,8 +147,9 @@ import {
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider
} from '@/components/ui/sidebar'
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'
const route = useRoute()
const emitter = useEmitter()
@@ -185,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

@@ -0,0 +1,12 @@
<template>
<TooltipProvider :delay-duration="150">
<Toaster class="pointer-events-auto" position="top-center" richColors />
<RouterView />
</TooltipProvider>
</template>
<script setup>
import { RouterView } from 'vue-router'
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,6 @@ const createOIDC = (data) =>
'Content-Type': 'application/json'
}
})
const testOIDC = (data) => http.post('/api/v1/oidc/test', data)
const getAllEnabledOIDC = () => http.get('/api/v1/oidc/enabled')
const getAllOIDC = () => http.get('/api/v1/oidc')
const getOIDC = (id) => http.get(`/api/v1/oidc/${id}`)
@@ -131,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 }
@@ -157,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) =>
@@ -175,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) =>
@@ -205,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, {
@@ -216,28 +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,
{
const upsertTags = (uuid, data) => http.post(`/api/v1/conversations/${uuid}/tags`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateAssignee = (uuid, assignee_type, data) =>
http.put(`/api/v1/conversations/${uuid}/assignee/${assignee_type}`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateConversationCustomAttribute = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/custom-attributes`, 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, {
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 createConversation = (data) => http.post('/api/v1/conversations', data)
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 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: {
@@ -248,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: {
@@ -277,7 +352,8 @@ const uploadMedia = (data) =>
}
})
const getOverviewCounts = () => http.get('/api/v1/reports/overview/counts')
const getOverviewCharts = () => http.get('/api/v1/reports/overview/charts')
const getOverviewCharts = (params) => http.get('/api/v1/reports/overview/charts', { params })
const getOverviewSLA = (params) => http.get('/api/v1/reports/overview/sla', { params })
const getLanguage = (lang) => http.get(`/api/v1/lang/${lang}`)
const createInbox = (data) =>
http.post('/api/v1/inboxes', data, {
@@ -310,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,
@@ -356,6 +470,7 @@ export default {
getViewConversations,
getOverviewCharts,
getOverviewCounts,
getOverviewSLA,
getConversationParticipants,
getConversationMessage,
getConversationMessages,
@@ -402,7 +517,6 @@ export default {
getAllEnabledOIDC,
getOIDC,
updateOIDC,
testOIDC,
deleteOIDC,
getTemplate,
getTemplates,
@@ -444,5 +558,14 @@ export default {
getContactNotes,
createContactNote,
deleteContactNote,
getActivityLogs
getActivityLogs,
getWebhooks,
getWebhook,
createWebhook,
updateWebhook,
deleteWebhook,
toggleWebhook,
testWebhook,
generateAPIKey,
revokeAPIKey
}

View File

@@ -0,0 +1,24 @@
<template>
<Button
variant="ghost"
@click.prevent="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"
>
<slot>
<X size="16" />
</slot>
</Button>
</template>
<script setup>
import { Button } from '@shared-ui/components/ui/button'
import { X } from 'lucide-vue-next'
defineProps({
onClose: {
type: Function,
required: true
}
})
</script>

View File

@@ -0,0 +1,61 @@
<template>
<ComboBox
:model-value="normalizedValue"
@update:model-value="$emit('update:modelValue', $event)"
:items="items"
:placeholder="placeholder"
>
<!-- Items -->
<template #item="{ item }">
<div class="flex items-center gap-2">
<!--USER -->
<Avatar v-if="type === 'user'" class="w-7 h-7">
<AvatarImage :src="item.avatar_url || ''" :alt="item.label.slice(0, 2)" />
<AvatarFallback>{{ item.label.slice(0, 2).toUpperCase() }}</AvatarFallback>
</Avatar>
<!-- Others -->
<span v-else-if="item.emoji">{{ item.emoji }}</span>
<span>{{ item.label }}</span>
</div>
</template>
<!-- Selected -->
<template #selected="{ selected }">
<div class="flex items-center gap-2">
<div v-if="selected" class="flex items-center gap-2">
<!--USER -->
<Avatar v-if="type === 'user'" class="w-7 h-7">
<AvatarImage :src="selected.avatar_url || ''" :alt="selected.label.slice(0, 2)" />
<AvatarFallback>{{ selected.label.slice(0, 2).toUpperCase() }}</AvatarFallback>
</Avatar>
<!-- Others -->
<span v-else-if="selected.emoji">{{ selected.emoji }}</span>
<span>{{ selected.label }}</span>
</div>
<span v-else>{{ placeholder }}</span>
</div>
</template>
</ComboBox>
</template>
<script setup>
import { computed } from '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],
placeholder: String,
items: Array,
type: {
type: String
}
})
// Convert to str.
const normalizedValue = computed(() => String(props.modelValue || ''))
defineEmits(['update:modelValue'])
</script>

View File

@@ -1,6 +1,6 @@
<template>
<div class="w-full">
<div class="rounded-md border shadow">
<div class="rounded border shadow">
<Table>
<TableHeader>
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
@@ -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,65 @@
<template>
<div ref="codeEditor" @click="editorView?.focus()" class="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 { 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
})
const emit = defineEmits(['update:modelValue'])
const data = ref('')
let editorView = null
const codeEditor = useTemplateRef('codeEditor')
const initCodeEditor = (body) => {
const isDark = useColorMode().value === 'dark'
editorView = new EditorView({
doc: body,
extensions: [
basicSetup,
html(),
...(isDark ? [oneDark] : []),
EditorView.editable.of(!props.disabled),
EditorView.theme({
'&': { height: '100%' },
'.cm-editor': { height: '100%' },
'.cm-scroller': { overflow: 'auto' }
}),
EditorView.updateListener.of((update) => {
if (!update.docChanged) return
const v = update.state.doc.toString()
emit('update:modelValue', v)
data.value = v
})
],
parent: codeEditor.value
})
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

@@ -4,7 +4,7 @@
:editor="editor"
:tippy-options="{ duration: 100 }"
v-if="editor"
class="bg-white p-1 box will-change-transform"
class="bg-background p-1 box will-change-transform"
>
<div class="flex space-x-1 items-center">
<DropdownMenu v-if="aiPrompts.length > 0">
@@ -30,26 +30,24 @@
<Button
size="sm"
variant="ghost"
@click.prevent="isBold = !isBold"
:active="isBold"
:class="{ 'bg-gray-200': isBold }"
@click.prevent="editor?.chain().focus().toggleBold().run()"
:class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('bold') }"
>
<Bold size="14" />
</Button>
<Button
size="sm"
variant="ghost"
@click.prevent="isItalic = !isItalic"
:active="isItalic"
:class="{ 'bg-gray-200': isItalic }"
@click.prevent="editor?.chain().focus().toggleItalic().run()"
:class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('italic') }"
>
<Italic size="14" />
</Button>
<Button
size="sm"
variant="ghost"
@click.prevent="toggleBulletList"
:class="{ 'bg-gray-200': editor?.isActive('bulletList') }"
@click.prevent="editor?.chain().focus().toggleBulletList().run()"
:class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('bulletList') }"
>
<List size="14" />
</Button>
@@ -57,8 +55,8 @@
<Button
size="sm"
variant="ghost"
@click.prevent="toggleOrderedList"
:class="{ 'bg-gray-200': editor?.isActive('orderedList') }"
@click.prevent="editor?.chain().focus().toggleOrderedList().run()"
:class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('orderedList') }"
>
<ListOrdered size="14" />
</Button>
@@ -66,16 +64,16 @@
size="sm"
variant="ghost"
@click.prevent="openLinkModal"
:class="{ 'bg-gray-200': editor?.isActive('link') }"
:class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('link') }"
>
<LinkIcon size="14" />
</Button>
<div v-if="showLinkInput" class="flex space-x-2 p-2 bg-white border rounded-lg">
<input
<div v-if="showLinkInput" class="flex space-x-2 p-2 bg-background border rounded">
<Input
v-model="linkUrl"
type="text"
placeholder="Enter link URL"
class="border p-1 text-sm"
class="border p-1 text-sm w-[200px]"
/>
<Button size="sm" @click="setLink">
<Check size="14" />
@@ -91,7 +89,7 @@
</template>
<script setup>
import { ref, watch, watchEffect, onUnmounted } from 'vue'
import { ref, watch, onUnmounted } from 'vue'
import { useEditor, EditorContent, BubbleMenu } from '@tiptap/vue-3'
import {
ChevronDown,
@@ -104,13 +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'
} 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'
@@ -119,22 +118,21 @@ 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 selectedText = defineModel('selectedText', { default: '' })
const textContent = defineModel('textContent')
const htmlContent = defineModel('htmlContent')
const isBold = defineModel('isBold')
const isItalic = defineModel('isItalic')
const cursorPosition = defineModel('cursorPosition', { default: 0 })
const textContent = defineModel('textContent', { default: '' })
const htmlContent = defineModel('htmlContent', { default: '' })
const showLinkInput = ref(false)
const linkUrl = ref('')
const props = defineProps({
placeholder: String,
contentToSet: String,
setInlineImage: Object,
insertContent: String,
clearContent: Boolean,
autoFocus: {
type: Boolean,
default: true
},
aiPrompts: {
type: Array,
default: () => []
@@ -145,7 +143,9 @@ const emit = defineEmits(['send', 'aiPromptSelected'])
const emitPrompt = (key) => emit('aiPromptSelected', key)
const getSelectionText = (from, to, doc) => doc.textBetween(from, to)
// Set up typing indicator
const conversationStore = useConversationStore()
const { startTyping, stopTyping } = useTypingIndicator(conversationStore.sendTyping)
// To preseve the table styling in emails, need to set the table style inline.
// Created these custom extensions to set the table style inline.
@@ -155,7 +155,7 @@ const CustomTable = Table.extend({
...this.parent?.(),
style: {
parseHTML: (element) =>
(element.getAttribute('style') || '') + ' border: 1px solid #dee2e6 !important; width: 100%; margin:0; table-layout: fixed; border-collapse: collapse; position:relative; border-radius: 0.25rem;'
(element.getAttribute('style') || '') + '; border: 1px solid #dee2e6 !important; width: 100%; margin:0; table-layout: fixed; border-collapse: collapse; position:relative; border-radius: 0.25rem;'
}
}
}
@@ -168,7 +168,7 @@ const CustomTableCell = TableCell.extend({
style: {
parseHTML: (element) =>
(element.getAttribute('style') || '') +
' border: 1px solid #dee2e6 !important; box-sizing: border-box !important; min-width: 1em !important; padding: 6px 8px !important; vertical-align: top !important;'
'; border: 1px solid #dee2e6 !important; box-sizing: border-box !important; min-width: 1em !important; padding: 6px 8px !important; vertical-align: top !important;'
}
}
}
@@ -181,137 +181,67 @@ const CustomTableHeader = TableHeader.extend({
style: {
parseHTML: (element) =>
(element.getAttribute('style') || '') +
' background-color: #f8f9fa !important; color: #212529 !important; font-weight: bold !important; text-align: left !important; border: 1px solid #dee2e6 !important; padding: 6px 8px !important;'
'; background-color: #f8f9fa !important; color: #212529 !important; font-weight: bold !important; text-align: left !important; border: 1px solid #dee2e6 !important; padding: 6px 8px !important;'
}
}
}
})
const editorConfig = {
const isInternalUpdate = ref(false)
const editor = useEditor({
extensions: [
StarterKit.configure(),
Image.configure({ HTMLAttributes: { class: 'inline-image' } }),
Placeholder.configure({ placeholder: () => props.placeholder }),
Link,
CustomTable.configure({
resizable: false
}),
CustomTable.configure({ resizable: false }),
TableRow,
CustomTableCell,
CustomTableHeader
],
autofocus: true,
autofocus: props.autoFocus,
content: htmlContent.value,
editorProps: {
attributes: { class: 'outline-none' },
handleKeyDown: (view, event) => {
if (event.ctrlKey && event.key === 'Enter') {
emit('send')
// Stop typing when sending
stopTyping()
return true
}
if (event.ctrlKey && event.key.toLowerCase() === 'b') {
// Prevent outer listeners
event.stopPropagation()
return false
}
}
}
}
},
// To update state when user types.
onUpdate: ({ editor }) => {
isInternalUpdate.value = true
htmlContent.value = editor.getHTML()
textContent.value = editor.getText()
isInternalUpdate.value = false
const editor = ref(
useEditor({
...editorConfig,
content: htmlContent.value,
onSelectionUpdate: ({ editor }) => {
const { from, to } = editor.state.selection
selectedText.value = getSelectionText(from, to, editor.state.doc)
},
onUpdate: ({ editor }) => {
htmlContent.value = editor.getHTML()
textContent.value = editor.getText()
cursorPosition.value = editor.state.selection.from
},
onCreate: ({ editor }) => {
if (cursorPosition.value) {
editor.commands.setTextSelection(cursorPosition.value)
}
}
})
)
watchEffect(() => {
const editorInstance = editor.value
if (!editorInstance) return
isBold.value = editorInstance.isActive('bold')
isItalic.value = editorInstance.isActive('italic')
})
watchEffect(() => {
const editorInstance = editor.value
if (!editorInstance) return
if (isBold.value !== editorInstance.isActive('bold')) {
isBold.value
? editorInstance.chain().focus().setBold().run()
: editorInstance.chain().focus().unsetBold().run()
}
if (isItalic.value !== editorInstance.isActive('italic')) {
isItalic.value
? editorInstance.chain().focus().setItalic().run()
: editorInstance.chain().focus().unsetItalic().run()
// Trigger typing indicator when user types
startTyping()
},
onBlur: () => {
// Stop typing when editor loses focus
stopTyping()
}
})
watch(
() => props.contentToSet,
(newContentData) => {
if (!newContentData) return
try {
const parsedData = JSON.parse(newContentData)
const content = parsedData.content
if (content === '') {
editor.value?.commands.clearContent()
} else {
editor.value?.commands.setContent(content, true)
}
editor.value?.commands.focus()
} catch (e) {
console.error('Error parsing content data', e)
htmlContent,
(newContent) => {
if (!isInternalUpdate.value && editor.value && newContent !== editor.value.getHTML()) {
editor.value.commands.setContent(newContent || '', false)
textContent.value = editor.value.getText()
editor.value.commands.focus()
}
}
)
watch(cursorPosition, (newPos, oldPos) => {
if (editor.value && newPos !== oldPos && newPos !== editor.value.state.selection.from) {
editor.value.commands.setTextSelection(newPos)
}
})
watch(
() => props.clearContent,
() => {
if (!props.clearContent) return
editor.value?.commands.clearContent()
editor.value?.commands.focus()
// `onUpdate` is not called when clearing content, so need to reset the content here.
htmlContent.value = ''
textContent.value = ''
cursorPosition.value = 0
}
)
watch(
() => props.setInlineImage,
(val) => {
if (val) {
editor.value?.commands.setImage({
src: val.src,
alt: val.alt,
title: val.title
})
}
}
},
{ immediate: true }
)
// Insert content at cursor position when insertContent prop changes.
watch(
() => props.insertContent,
(val) => {
@@ -323,18 +253,6 @@ onUnmounted(() => {
editor.value?.destroy()
})
const toggleBulletList = () => {
if (editor.value) {
editor.value.chain().focus().toggleBulletList().run()
}
}
const toggleOrderedList = () => {
if (editor.value) {
editor.value.chain().focus().toggleOrderedList().run()
}
}
const openLinkModal = () => {
if (editor.value?.isActive('link')) {
linkUrl.value = editor.value.getAttributes('link').href

View File

@@ -11,8 +11,12 @@
<!-- Field -->
<div class="flex-1">
<Select v-model="modelFilter.field">
<SelectTrigger class="bg-transparent hover:bg-slate-100 w-full">
<SelectValue :placeholder="t('form.field.selectField')" />
<SelectTrigger>
<SelectValue
:placeholder="
t('globals.messages.select', { name: t('globals.terms.field').toLowerCase() })
"
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
@@ -27,8 +31,12 @@
<!-- Operator -->
<div class="flex-1">
<Select v-model="modelFilter.operator" v-if="modelFilter.field">
<SelectTrigger class="bg-transparent hover:bg-slate-100 w-full">
<SelectValue :placeholder="t('form.field.selectOperator')" />
<SelectTrigger>
<SelectValue
:placeholder="
t('globals.messages.select', { name: t('globals.terms.operator').toLowerCase() })
"
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
@@ -44,79 +52,46 @@
<div class="flex-1">
<div v-if="modelFilter.field && modelFilter.operator">
<template v-if="modelFilter.operator !== 'set' && modelFilter.operator !== 'not set'">
<ComboBox
v-if="getFieldOptions(modelFilter).length > 0"
<SelectComboBox
v-if="
getFieldOptions(modelFilter).length > 0 &&
modelFilter.field === 'assigned_user_id'
"
v-model="modelFilter.value"
:items="getFieldOptions(modelFilter)"
:placeholder="t('form.field.select')"
>
<template #item="{ item }">
<div v-if="modelFilter.field === 'assigned_user_id'">
<div class="flex items-center gap-1">
<Avatar class="w-6 h-6">
<AvatarImage :src="item.avatar_url || ''" :alt="item.label.slice(0, 2)" />
<AvatarFallback>{{ item.label.slice(0, 2).toUpperCase() }} </AvatarFallback>
</Avatar>
<span>{{ item.label }}</span>
</div>
</div>
<div v-else-if="modelFilter.field === 'assigned_team_id'">
<div class="flex items-center gap-2 ml-2">
<span>{{ item.emoji }}</span>
<span>{{ item.label }}</span>
</div>
</div>
<div v-else>
{{ item.label }}
</div>
</template>
:placeholder="t('globals.messages.select', { name: '' })"
type="user"
/>
<SelectComboBox
v-else-if="
getFieldOptions(modelFilter).length > 0 &&
modelFilter.field === 'assigned_team_id'
"
v-model="modelFilter.value"
:items="getFieldOptions(modelFilter)"
:placeholder="t('globals.messages.select', { name: '' })"
type="team"
/>
<SelectComboBox
v-else-if="getFieldOptions(modelFilter).length > 0"
v-model="modelFilter.value"
:items="getFieldOptions(modelFilter)"
:placeholder="t('globals.messages.select', { name: '' })"
/>
<template #selected="{ selected }">
<div v-if="!selected">{{ $t('form.field.selectValue') }}</div>
<div v-if="modelFilter.field === 'assigned_user_id'">
<div class="flex items-center gap-2">
<div v-if="selected" class="flex items-center gap-1">
<Avatar class="w-6 h-6">
<AvatarImage
:src="selected.avatar_url || ''"
:alt="selected.label.slice(0, 2)"
/>
<AvatarFallback>{{
selected.label.slice(0, 2).toUpperCase()
}}</AvatarFallback>
</Avatar>
<span>{{ selected.label }}</span>
</div>
</div>
</div>
<div v-else-if="modelFilter.field === 'assigned_team_id'">
<div class="flex items-center gap-2">
<span v-if="selected">
{{ selected.emoji }}
<span>{{ selected.label }}</span>
</span>
</div>
</div>
<div v-else-if="selected">
{{ selected.label }}
</div>
</template>
</ComboBox>
<Input
v-else
v-model="modelFilter.value"
class="bg-transparent hover:bg-slate-100"
:placeholder="t('form.field.value')"
:placeholder="t('globals.terms.value')"
type="text"
/>
</template>
</div>
</div>
</div>
<button @click="removeFilter(index)" class="p-1 hover:bg-slate-100 rounded">
<X class="w-4 h-4 text-slate-500" />
</button>
<CloseButton :onClose="() => removeFilter(index)" />
</div>
<div class="flex items-center justify-between pt-3">
@@ -129,8 +104,8 @@
}}
</Button>
<div class="flex gap-2" v-if="showButtons">
<Button variant="ghost" @click="clearFilters">{{ $t('globals.buttons.reset') }}</Button>
<Button @click="applyFilters">{{ $t('globals.buttons.apply') }}</Button>
<Button variant="ghost" @click="clearFilters">{{ $t('globals.messages.reset') }}</Button>
<Button @click="applyFilters">{{ $t('globals.messages.apply') }}</Button>
</div>
</div>
</div>
@@ -145,13 +120,13 @@ import {
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { Plus, X } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
} from '@shared-ui/components/ui/select'
import { Plus } from 'lucide-vue-next'
import { Button } from '@shared-ui/components/ui/button'
import { Input } from '@shared-ui/components/ui/input'
import { useI18n } from 'vue-i18n'
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
import CloseButton from '@main/components/button/CloseButton.vue'
import SelectComboBox from '@main/components/combobox/SelectCombobox.vue'
const props = defineProps({
fields: {

View File

@@ -1,6 +1,6 @@
<template>
<div
class="flex flex-col p-4 border rounded-lg shadow-sm hover:shadow transition-colors cursor-pointer max-w-xs"
class="flex flex-col p-4 border rounded shadow-sm hover:shadow transition-colors cursor-pointer max-w-xs"
@click="handleClick">
<div class="flex items-center mb-2">
<component :is="icon" size="24" class="mr-2 text-primary" />
@@ -11,7 +11,7 @@
</template>
<script setup>
import { defineProps, defineEmits } from 'vue'
import { defineEmits } from 'vue'
const props = defineProps({
title: String,

View File

@@ -1,8 +1,8 @@
<template>
<div v-if="!isHidden">
<div class="flex items-center space-x-4 h-12 px-2">
<SidebarTrigger class="cursor-pointer w-4 h-4" />
<span class="text-xl font-semibold text-gray-800">
<SidebarTrigger class="cursor-pointer" />
<span class="text-xl font-semibold">
{{ title }}
</span>
</div>
@@ -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,
@@ -14,7 +14,6 @@ import {
SidebarHeader,
SidebarInset,
SidebarMenu,
SidebarSeparator,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
@@ -22,39 +21,50 @@ 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,
User,
UserSearch,
UsersRound,
Search,
Plus
Plus,
CircleDashed,
List
} from 'lucide-vue-next'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { filterNavItems } from '@/utils/nav-permissions'
} from '@shared-ui/components/ui/dropdown-menu'
import { filterNavItems } from '../../utils/nav-permissions'
import { useStorage } from '@vueuse/core'
import { computed } from 'vue'
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'])
const isActiveParent = (parentHref) => {
return route.path.startsWith(parentHref)
}
const isInboxRoute = (path) => {
return path.startsWith('/inboxes')
}
const openCreateViewDialog = () => {
emit('createView')
}
@@ -67,18 +77,83 @@ const deleteView = (view) => {
emit('deleteView', view)
}
// 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))
const filteredReportsNavItems = computed(() => filterNavItems(reportsNavItems, userStore.can))
const filteredContactsNavItems = computed(() => filterNavItems(contactNavItems, userStore.can))
const isActiveParent = (parentHref) => {
return route.path.startsWith(parentHref)
}
const isInboxRoute = (path) => {
return path.startsWith('/inboxes')
// For auto opening admin collapsibles when a child route is active
const openAdminCollapsible = ref(null)
const toggleAdminCollapsible = (titleKey) => {
openAdminCollapsible.value = openAdminCollapsible.value === titleKey ? null : titleKey
}
// Watch for route changes and update the active collapsible
watch(
[() => route.path, filteredAdminNavItems],
() => {
const activeItem = filteredAdminNavItems.value.find((item) => {
if (!item.children) return isActiveParent(item.href)
return item.children.some((child) => isActiveParent(child.href))
})
if (activeItem) {
openAdminCollapsible.value = activeItem.titleKey
}
},
{ immediate: true }
)
// Sidebar open state in local storage
const sidebarOpen = useStorage('mainSidebarOpen', true)
const teamInboxOpen = useStorage('teamInboxOpen', true)
const viewInboxOpen = useStorage('viewInboxOpen', true)
@@ -98,24 +173,25 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton :isActive="isActiveParent('/contacts')" asChild>
<div>
<span class="font-semibold text-xl">
{{ t('globals.terms.contact', 2) }}
</span>
</div>
</SidebarMenuButton>
<div class="px-1">
<span class="font-semibold text-xl">
{{ t('globals.terms.contact', 2) }}
</span>
</div>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarSeparator />
<SidebarContent>
<SidebarGroup>
<SidebarMenu>
<SidebarMenuItem v-for="item in filteredContactsNavItems" :key="item.titleKey">
<SidebarMenuButton :isActive="isActiveParent(item.href)" asChild>
<router-link :to="item.href">
<span>{{ t(item.titleKey) }}</span>
<span>{{
t('globals.messages.all', {
name: t(item.titleKey, 2).toLowerCase()
})
}}</span>
</router-link>
</SidebarMenuButton>
</SidebarMenuItem>
@@ -137,17 +213,14 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton :isActive="isActiveParent('/reports/overview')" asChild>
<div>
<span class="font-semibold text-xl">
{{ t('navigation.reports') }}
</span>
</div>
</SidebarMenuButton>
<div class="px-1">
<span class="font-semibold text-xl">
{{ t('globals.terms.report', 2) }}
</span>
</div>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarSeparator />
<SidebarContent>
<SidebarGroup>
<SidebarMenu>
@@ -171,21 +244,18 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton :isActive="isActiveParent('/admin')" asChild>
<div class="flex items-center justify-between w-full">
<span class="font-semibold text-xl">
{{ t('navigation.admin') }}
</span>
</div>
<div class="flex flex-col items-start justify-between w-full px-1">
<span class="font-semibold text-xl">
{{ t('globals.terms.admin') }}
</span>
<!-- App version -->
<div class="text-xs text-muted-foreground ml-2">
<div class="text-xs text-muted-foreground">
({{ settingsStore.settings['app.version'] }})
</div>
</SidebarMenuButton>
</div>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarSeparator />
<SidebarContent>
<SidebarGroup>
<SidebarMenu>
@@ -203,11 +273,12 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
<Collapsible
v-else
class="group/collapsible"
:default-open="isActiveParent(item.href)"
:open="openAdminCollapsible === item.titleKey"
@update:open="toggleAdminCollapsible(item.titleKey)"
>
<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"
/>
@@ -239,17 +310,14 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton :isActive="isActiveParent('/account/profile')" asChild>
<div>
<span class="font-semibold text-xl">
{{ t('navigation.account') }}
</span>
</div>
</SidebarMenuButton>
<div class="px-1">
<span class="font-semibold text-xl">
{{ t('globals.terms.account') }}
</span>
</div>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarSeparator />
<SidebarContent>
<SidebarGroup>
<SidebarMenu>
@@ -276,28 +344,20 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild>
<div class="flex items-center justify-between w-full">
<div class="font-semibold text-xl">
<span>{{ t('navigation.inbox') }}</span>
</div>
<div class="ml-auto">
<div class="flex items-center space-x-2">
<router-link :to="{ name: 'search' }">
<button
class="flex items-center bg-accent p-2 rounded-full hover:scale-110 transition-transform duration-100"
>
<Search size="15" stroke-width="2.5" />
</button>
</router-link>
</div>
</div>
<div class="flex items-center justify-between w-full px-1">
<div class="font-semibold text-xl">
<span>{{ t('globals.terms.inbox') }}</span>
</div>
</SidebarMenuButton>
<div class="mr-1 mt-1 hover:scale-110 transition-transform">
<router-link :to="{ name: 'search' }">
<Search size="18" stroke-width="2.5" />
</router-link>
</div>
</div>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarSeparator />
<SidebarContent>
<SidebarGroup>
<SidebarMenu>
@@ -317,32 +377,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('navigation.myInbox') }}</span>
</router-link>
<span>{{ t('globals.terms.myInbox') }}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild :isActive="isActiveParent('/inboxes/unassigned')">
<router-link :to="{ name: 'inbox', params: { type: 'unassigned' } }">
<UserSearch />
<a href="#" @click.prevent="navigateToInbox('unassigned')">
<CircleDashed />
<span>
{{ t('navigation.unassigned') }}
{{ 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' } }">
<UsersRound />
<a href="#" @click.prevent="navigateToInbox('all')">
<List />
<span>
{{ t('navigation.all') }}
{{ t('globals.messages.all') }}
</span>
</router-link>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
@@ -359,7 +419,7 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
<router-link to="#">
<!-- <Users /> -->
<span>
{{ t('navigation.teamInboxes') }}
{{ t('globals.terms.teamInbox', 2) }}
</span>
<ChevronRight
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
@@ -375,9 +435,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>
@@ -388,18 +448,18 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
<!-- Views -->
<Collapsible class="group/collapsible" defaultOpen v-model:open="viewInboxOpen">
<SidebarMenuItem>
<CollapsibleTrigger as-child>
<CollapsibleTrigger asChild>
<SidebarMenuButton asChild>
<router-link to="#" class="group/item">
<router-link to="#" class="group/item !p-2">
<!-- <SlidersHorizontal /> -->
<span>
{{ t('navigation.views') }}
{{ t('globals.terms.view', 2) }}
</span>
<div>
<Plus
size="18"
@click.stop="openCreateViewDialog"
class="rounded-lg cursor-pointer opacity-0 transition-all duration-200 group-hover/item:opacity-100 hover:bg-gray-200 hover:shadow-sm text-gray-600 hover:text-gray-800 transform hover:scale-105 active:scale-100 p-1"
class="rounded cursor-pointer opacity-0 transition-all duration-200 group-hover/item:opacity-100 hover:bg-gray-200 hover:shadow-sm text-gray-600 hover:text-gray-800 transform hover:scale-105 active:scale-100 p-1"
/>
</div>
<ChevronRight
@@ -418,7 +478,7 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
:isActive="route.params.viewID == view.id"
asChild
>
<router-link :to="{ name: 'view-inbox', params: { viewID: view.id } }">
<a href="#" @click.prevent="navigateToViewInbox(view.id)">
<span class="break-words w-32 truncate">{{ view.name }}</span>
<SidebarMenuAction :showOnHover="true" class="mr-3">
<DropdownMenu>
@@ -427,15 +487,15 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="() => editView(view)">
<span>{{ t('globals.buttons.edit') }}</span>
<span>{{ t('globals.messages.edit') }}</span>
</DropdownMenuItem>
<DropdownMenuItem @click="() => deleteView(view)">
<span>{{ t('globals.buttons.delete') }}</span>
<span>{{ t('globals.messages.delete') }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuAction>
</router-link>
</a>
</SidebarMenuButton>
</SidebarMenuSubItem>
</SidebarMenuSub>

View File

@@ -0,0 +1,139 @@
<template>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<SidebarMenuButton
size="md"
class="p-0"
>
<Avatar class="h-8 w-8 rounded relative overflow-visible">
<AvatarImage :src="userStore.avatar" alt="U" class="rounded" />
<AvatarFallback class="rounded">
{{ userStore.getInitials }}
</AvatarFallback>
<div
class="absolute bottom-0 right-0 h-2.5 w-2.5 rounded-full border border-background"
:class="{
'bg-green-500': userStore.user.availability_status === 'online',
'bg-amber-500':
userStore.user.availability_status === 'away' ||
userStore.user.availability_status === 'away_manual' ||
userStore.user.availability_status === 'away_and_reassigning',
'bg-gray-400': userStore.user.availability_status === 'offline'
}"
></div>
</Avatar>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">{{ userStore.getFullName }}</span>
<span class="truncate text-xs">{{ userStore.email }}</span>
</div>
<ChevronsUpDown class="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
class="w-[--radix-dropdown-menu-trigger-width] min-w-56"
side="bottom"
:side-offset="4"
>
<DropdownMenuLabel class="font-normal space-y-2 px-2">
<!-- User header -->
<div class="flex items-center gap-2 py-1.5 text-left text-sm">
<Avatar class="h-8 w-8 rounded">
<AvatarImage :src="userStore.avatar" alt="U" />
<AvatarFallback class="rounded">
{{ userStore.getInitials }}
</AvatarFallback>
</Avatar>
<div class="flex-1 flex flex-col leading-tight">
<span class="truncate font-semibold">{{ userStore.getFullName }}</span>
<span class="truncate text-xs text-muted-foreground">{{ userStore.email }}</span>
</div>
</div>
<div class="space-y-2">
<!-- Dark-mode toggle -->
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2">
<Moon v-if="mode === 'dark'" size="16" class="text-muted-foreground" />
<Sun v-else size="16" class="text-muted-foreground" />
<span class="text-muted-foreground">{{ t('navigation.darkMode') }}</span>
</div>
<Switch
:checked="mode === 'dark'"
@update:checked="(val) => (mode = val ? 'dark' : 'light')"
/>
</div>
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 space-y-3">
<!-- Away toggle -->
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">{{ t('navigation.away') }}</span>
<Switch
:checked="
['away_manual', 'away_and_reassigning'].includes(
userStore.user.availability_status
)
"
@update:checked="
(val) => userStore.updateUserAvailability(val ? 'away_manual' : 'online')
"
/>
</div>
<!-- Reassign toggle -->
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">{{ t('navigation.reassignReplies') }}</span>
<Switch
:checked="userStore.user.availability_status === 'away_and_reassigning'"
@update:checked="
(val) =>
userStore.updateUserAvailability(val ? 'away_and_reassigning' : 'away_manual')
"
/>
</div>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem @click.prevent="router.push({ name: 'account' })">
<CircleUserRound size="18" class="mr-2" />
{{ t('globals.terms.account') }}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem @click="logout">
<LogOut size="18" class="mr-2" />
{{ t('navigation.logout') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} 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 { useRouter } from 'vue-router'
import { useColorMode } from '@vueuse/core'
const mode = useColorMode()
const userStore = useUserStore()
const router = useRouter()
const { t } = useI18n()
const logout = () => {
window.location.href = '/logout'
}
</script>

View File

@@ -0,0 +1,112 @@
<template>
<table class="min-w-full table-fixed divide-y divide-border">
<thead class="bg-muted">
<tr>
<th
v-for="(header, index) in headers"
:key="index"
scope="col"
class="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider"
>
{{ header }}
</th>
<th v-if="showDelete" scope="col" class="relative px-6 py-3"></th>
</tr>
</thead>
<tbody class="bg-background divide-y divide-border">
<!-- Loading State -->
<template v-if="loading">
<tr v-for="i in skeletonRows" :key="`skeleton-${i}`" class="hover:bg-accent">
<td
v-for="(header, index) in headers"
:key="`skeleton-cell-${index}`"
class="px-6 py-3 text-sm font-medium text-foreground whitespace-normal break-words"
>
<Skeleton class="h-4 w-[85%]" />
</td>
<td v-if="showDelete" class="px-6 py-4 text-sm text-muted-foreground">
<Skeleton class="h-8 w-8 rounded" />
</td>
</tr>
</template>
<!-- No Results State -->
<template v-else-if="data.length === 0">
<tr>
<td :colspan="headers.length + (showDelete ? 1 : 0)" class="px-6 py-12 text-center">
<div class="flex flex-col items-center space-y-4">
<span class="text-md text-muted-foreground">
{{
$t('globals.messages.noResults', {
name: $t('globals.terms.result', 2).toLowerCase()
})
}}
</span>
</div>
</td>
</tr>
</template>
<!-- Data Rows -->
<template v-else>
<tr v-for="(item, index) in data" :key="index" class="hover:bg-accent">
<td
v-for="key in keys"
:key="key"
class="px-6 py-4 text-sm font-medium text-foreground whitespace-normal break-words"
>
{{ item[key] }}
</td>
<td v-if="showDelete" class="px-6 py-4 text-sm text-muted-foreground">
<Button size="xs" variant="ghost" @click.prevent="deleteItem(item)">
<Trash2 class="h-4 w-4" />
</Button>
</td>
</tr>
</template>
</tbody>
</table>
</template>
<script setup>
import { Trash2 } from 'lucide-vue-next'
import { defineEmits } from 'vue'
import { Button } from '@shared-ui/components/ui/button'
import { Skeleton } from '@shared-ui/components/ui/skeleton'
defineProps({
headers: {
type: Array,
required: true,
default: () => []
},
keys: {
type: Array,
required: true,
default: () => []
},
data: {
type: Array,
required: true,
default: () => []
},
showDelete: {
type: Boolean,
default: true
},
loading: {
type: Boolean,
default: false
},
skeletonRows: {
type: Number,
default: 5
}
})
const emit = defineEmits(['deleteItem'])
function deleteItem(item) {
emit('deleteItem', item)
}
</script>

View File

@@ -20,6 +20,6 @@
</template>
<script setup>
import { useAppSettingsStore } from '@/stores/appSettings'
import { useAppSettingsStore } from '../../stores/appSettings'
const appSettingsStore = useAppSettingsStore()
</script>

View File

@@ -1,18 +1,22 @@
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 () {
const uStore = useUsersStore()
const { t } = useI18n()
const activityLogListFilters = computed(() => ({
actor_id: {
label: 'Actor',
label: t('globals.terms.actor'),
type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT,
options: uStore.options
},
activity_type: {
label: 'Activity type',
label: t('globals.messages.type', {
name: t('globals.terms.activityLog')
}),
type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT,
options: [{

View File

@@ -1,11 +1,12 @@
import { computed } from 'vue'
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 { useCustomAttributeStore } from '@/stores/customAttributes'
import { FIELD_TYPE, FIELD_OPERATORS } from '@/constants/filterConfig'
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 { useCustomAttributeStore } from '../stores/customAttributes'
import { FIELD_TYPE, FIELD_OPERATORS } from '../constants/filterConfig'
import { useI18n } from 'vue-i18n'
export function useConversationFilters () {
const cStore = useConversationStore()
@@ -14,6 +15,7 @@ export function useConversationFilters () {
const tStore = useTeamStore()
const slaStore = useSlaStore()
const customAttributeStore = useCustomAttributeStore()
const { t } = useI18n()
const customAttributeDataTypeToFieldType = {
'text': FIELD_TYPE.TEXT,
@@ -35,31 +37,35 @@ export function useConversationFilters () {
const conversationsListFilters = computed(() => ({
status_id: {
label: 'Status',
label: t('globals.terms.status'),
type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT,
options: cStore.statusOptions
},
priority_id: {
label: 'Priority',
label: t('globals.terms.priority'),
type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT,
options: cStore.priorityOptions
},
assigned_team_id: {
label: 'Assigned team',
label: t('globals.messages.assign', {
name: t('globals.terms.team').toLowerCase()
}),
type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT,
options: tStore.options
},
assigned_user_id: {
label: 'Assigned user',
label: t('globals.messages.assign', {
name: t('globals.terms.agent').toLowerCase()
}),
type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT,
options: uStore.options
},
inbox_id: {
label: 'Inbox',
label: t('globals.terms.inbox'),
type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT,
options: iStore.options
@@ -85,46 +91,50 @@ export function useConversationFilters () {
const newConversationFilters = computed(() => ({
contact_email: {
label: 'Email',
label: t('globals.terms.email'),
type: FIELD_TYPE.TEXT,
operators: FIELD_OPERATORS.TEXT
},
content: {
label: 'Content',
label: t('globals.terms.content'),
type: FIELD_TYPE.TEXT,
operators: FIELD_OPERATORS.TEXT
},
subject: {
label: 'Subject',
label: t('globals.terms.subject'),
type: FIELD_TYPE.TEXT,
operators: FIELD_OPERATORS.TEXT
},
status: {
label: 'Status',
label: t('globals.terms.status'),
type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT,
options: cStore.statusOptions
},
priority: {
label: 'Priority',
label: t('globals.terms.priority'),
type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT,
options: cStore.priorityOptions
},
assigned_team: {
label: 'Assigned team',
label: t('globals.messages.assign', {
name: t('globals.terms.team').toLowerCase()
}),
type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT,
options: tStore.options
},
assigned_user: {
label: 'Assigned agent',
label: t('globals.messages.assign', {
name: t('globals.terms.agent').toLowerCase()
}),
type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT,
options: uStore.options
},
inbox: {
label: 'Inbox',
label: t('globals.terms.inbox'),
type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT,
options: iStore.options
@@ -133,51 +143,55 @@ export function useConversationFilters () {
const conversationFilters = computed(() => ({
status: {
label: 'Status',
label: t('globals.terms.status'),
type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT,
options: cStore.statusOptions
},
priority: {
label: 'Priority',
label: t('globals.terms.priority'),
type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT,
options: cStore.priorityOptions
},
assigned_team: {
label: 'Assigned team',
label: t('globals.messages.assign', {
name: t('globals.terms.team').toLowerCase()
}),
type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT,
options: tStore.options
},
assigned_user: {
label: 'Assigned agent',
label: t('globals.messages.assign', {
name: t('globals.terms.agent').toLowerCase()
}),
type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT,
options: uStore.options
},
hours_since_created: {
label: 'Hours since created',
label: t('globals.messages.hoursSinceCreated'),
type: FIELD_TYPE.NUMBER,
operators: FIELD_OPERATORS.NUMBER
},
hours_since_first_reply: {
label: 'Hours since first reply',
label: t('globals.messages.hoursSinceFirstReply'),
type: FIELD_TYPE.NUMBER,
operators: FIELD_OPERATORS.NUMBER
},
hours_since_last_reply: {
label: 'Hours since last reply',
label: t('globals.messages.hoursSinceLastReply'),
type: FIELD_TYPE.NUMBER,
operators: FIELD_OPERATORS.NUMBER
},
hours_since_resolved: {
label: 'Hours since resolved',
label: t('globals.messages.hoursSinceResolved'),
type: FIELD_TYPE.NUMBER,
operators: FIELD_OPERATORS.NUMBER
},
inbox: {
label: 'Inbox',
label: t('globals.terms.inbox'),
type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT,
options: iStore.options
@@ -186,86 +200,122 @@ export function useConversationFilters () {
const conversationActions = computed(() => ({
assign_team: {
label: 'Assign to team',
label: t('globals.messages.assign', {
name: t('globals.terms.team').toLowerCase()
}),
type: FIELD_TYPE.SELECT,
options: tStore.options
},
assign_user: {
label: 'Assign to user',
label: t('globals.messages.assign', {
name: t('globals.terms.agent').toLowerCase()
}),
type: FIELD_TYPE.SELECT,
options: uStore.options
},
set_status: {
label: 'Set status',
label: t('globals.messages.set', {
name: t('globals.terms.status').toLowerCase()
}),
type: FIELD_TYPE.SELECT,
options: cStore.statusOptionsNoSnooze
},
set_priority: {
label: 'Set priority',
label: t('globals.messages.set', {
name: t('globals.terms.priority').toLowerCase()
}),
type: FIELD_TYPE.SELECT,
options: cStore.priorityOptions
},
send_private_note: {
label: 'Send private note',
label: t('globals.messages.send', {
name: t('globals.terms.privateNote').toLowerCase()
}),
type: FIELD_TYPE.RICHTEXT
},
send_reply: {
label: 'Send reply',
label: t('globals.messages.send', {
name: t('globals.terms.reply').toLowerCase()
}),
type: FIELD_TYPE.RICHTEXT
},
send_csat: {
label: 'Send CSAT',
label: t('globals.messages.send', {
name: t('globals.terms.csat').toLowerCase()
}),
},
set_sla: {
label: 'Set SLA',
label: t('globals.messages.set', {
name: t('globals.terms.sla').toLowerCase()
}),
type: FIELD_TYPE.SELECT,
options: slaStore.options
},
add_tags: {
label: 'Add tags',
label: t('globals.messages.add', {
name: t('globals.terms.tag', 2).toLowerCase()
}),
type: FIELD_TYPE.TAG
},
set_tags: {
label: 'Set tags',
label: t('globals.messages.set', {
name: t('globals.terms.tag', 2).toLowerCase()
}),
type: FIELD_TYPE.TAG
},
remove_tags: {
label: 'Remove tags',
label: t('globals.messages.remove', {
name: t('globals.terms.tag', 2).toLowerCase()
}),
type: FIELD_TYPE.TAG
}
}))
const macroActions = computed(() => ({
assign_team: {
label: 'Assign to team',
label: t('globals.messages.assign', {
name: t('globals.terms.team').toLowerCase()
}),
type: FIELD_TYPE.SELECT,
options: tStore.options
},
assign_user: {
label: 'Assign to user',
label: t('globals.messages.assign', {
name: t('globals.terms.agent').toLowerCase()
}),
type: FIELD_TYPE.SELECT,
options: uStore.options
},
set_status: {
label: 'Set status',
label: t('globals.messages.set', {
name: t('globals.terms.status').toLowerCase()
}),
type: FIELD_TYPE.SELECT,
options: cStore.statusOptionsNoSnooze
},
set_priority: {
label: 'Set priority',
label: t('globals.messages.set', {
name: t('globals.terms.priority').toLowerCase()
}),
type: FIELD_TYPE.SELECT,
options: cStore.priorityOptions
},
add_tags: {
label: 'Add tags',
label: t('globals.messages.add', {
name: t('globals.terms.tag', 2).toLowerCase()
}),
type: FIELD_TYPE.TAG
},
set_tags: {
label: 'Set tags',
label: t('globals.messages.set', {
name: t('globals.terms.tag', 2).toLowerCase()
}),
type: FIELD_TYPE.TAG
},
remove_tags: {
label: 'Remove tags',
label: t('globals.messages.remove', {
name: t('globals.terms.tag', 2).toLowerCase()
}),
type: FIELD_TYPE.TAG
}
}))

View File

@@ -0,0 +1,142 @@
import { ref, readonly } from 'vue'
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
* @param {Object} options - Configuration options
* @param {Function} options.onFileUploadSuccess - Callback when file upload succeeds (uploadedFile)
* @param {Function} options.onUploadError - Optional callback when file upload fails (file, error)
* @param {string} options.linkedModel - The linked model for the upload
* @param {Array} options.mediaFiles - Optional external array to manage files (if not provided, internal array is used)
*/
export function useFileUpload (options = {}) {
const {
onFileUploadSuccess,
onUploadError,
linkedModel,
mediaFiles: externalMediaFiles
} = options
const emitter = useEmitter()
const uploadingFiles = ref([])
const isUploading = ref(false)
const internalMediaFiles = ref([])
// Use external mediaFiles if provided, otherwise use internal
const mediaFiles = externalMediaFiles || internalMediaFiles
/**
* Handles the file upload process when files are selected.
* Uploads each file to the server and adds them to the mediaFiles array.
* @param {Event} event - The file input change event containing selected files
*/
const handleFileUpload = (event) => {
const files = Array.from(event.target.files)
uploadingFiles.value = files
isUploading.value = true
for (const file of files) {
api
.uploadMedia({
files: file,
inline: false,
linked_model: linkedModel
})
.then((resp) => {
const uploadedFile = resp.data.data
// Add to media files array
if (Array.isArray(mediaFiles.value)) {
mediaFiles.value.push(uploadedFile)
} else {
mediaFiles.push(uploadedFile)
}
// Remove from uploading list
uploadingFiles.value = uploadingFiles.value.filter((f) => f.name !== file.name)
// Call success callback
if (onFileUploadSuccess) {
onFileUploadSuccess(uploadedFile)
}
// Update uploading state
if (uploadingFiles.value.length === 0) {
isUploading.value = false
}
})
.catch((error) => {
uploadingFiles.value = uploadingFiles.value.filter((f) => f.name !== file.name)
// Call error callback or show default toast
if (onUploadError) {
onUploadError(file, error)
} else {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
}
// Update uploading state
if (uploadingFiles.value.length === 0) {
isUploading.value = false
}
})
}
}
/**
* Handles the file delete event.
* Removes the file from the mediaFiles array.
* @param {String} uuid - The UUID of the file to delete
*/
const handleFileDelete = (uuid) => {
if (Array.isArray(mediaFiles.value)) {
mediaFiles.value = [
...mediaFiles.value.filter((item) => item.uuid !== uuid)
]
} else {
const index = mediaFiles.findIndex((item) => item.uuid === uuid)
if (index > -1) {
mediaFiles.splice(index, 1)
}
}
}
/**
* Upload files programmatically (without event)
* @param {File[]} files - Array of files to upload
*/
const uploadFiles = (files) => {
const mockEvent = { target: { files } }
handleFileUpload(mockEvent)
}
/**
* Clear all media files
*/
const clearMediaFiles = () => {
if (Array.isArray(mediaFiles.value)) {
mediaFiles.value = []
} else {
mediaFiles.length = 0
}
}
return {
// State
uploadingFiles: readonly(uploadingFiles),
isUploading: readonly(isUploading),
mediaFiles: externalMediaFiles ? readonly(mediaFiles) : readonly(internalMediaFiles),
// Methods
handleFileUpload,
handleFileDelete,
uploadFiles,
clearMediaFiles
}
}

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

@@ -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,42 @@
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',
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,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

@@ -44,7 +44,7 @@
</SelectTrigger>
<SelectContent>
<SelectItem :value="'activity_logs.created_at'">
{{ t('form.field.createdAt') }}
{{ t('globals.terms.createdAt') }}
</SelectItem>
</SelectContent>
</Select>
@@ -63,35 +63,20 @@
</Popover>
</div>
<div v-if="loading" class="w-full">
<div class="flex border-b border-border p-4 font-medium bg-gray-50">
<div class="flex-1 text-muted-foreground">{{ t('form.field.name') }}</div>
<div class="w-[200px] text-muted-foreground">{{ t('form.field.date') }}</div>
<div class="w-[150px] text-muted-foreground">{{ t('globals.terms.ipAddress') }}</div>
</div>
<div v-for="i in perPage" :key="i" class="flex border-b border-border py-3 px-4">
<div class="flex-1">
<Skeleton class="h-4 w-[90%]" />
</div>
<div class="w-[200px]">
<Skeleton class="h-4 w-[120px]" />
</div>
<div class="w-[150px]">
<Skeleton class="h-4 w-[100px]" />
</div>
</div>
<div class="w-full overflow-x-auto">
<SimpleTable
:headers="[
t('globals.terms.name'),
t('globals.terms.timestamp'),
t('globals.terms.ipAddress')
]"
:keys="['activity_description', 'created_at', 'ip']"
:data="activityLogs"
:showDelete="false"
:loading="loading"
:skeletonRows="15"
/>
</div>
<template v-else>
<div class="w-full overflow-x-auto">
<SimpleTable
:headers="[t('form.field.name'), t('form.field.timestamp'), t('globals.terms.ipAddress')]"
:keys="['activity_description', 'created_at', 'ip']"
:data="activityLogs"
:showDelete="false"
/>
</div>
</template>
</div>
<!-- TODO: deduplicate this code, copied from contacts list -->
@@ -163,8 +148,7 @@
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { Skeleton } from '@/components/ui/skeleton'
import SimpleTable from '@/components/table/SimpleTable.vue'
import SimpleTable from '@main/components/table/SimpleTable.vue'
import {
Pagination,
PaginationEllipsis,
@@ -174,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

@@ -0,0 +1,540 @@
<template>
<form @submit.prevent="onSubmit" class="space-y-8">
<!-- Summary Section -->
<div class="bg-muted/30 box py-6 px-3" v-if="!isNewForm">
<div class="flex items-start gap-6">
<Avatar class="w-20 h-20">
<AvatarImage :src="props.initialValues.avatar_url || ''" :alt="Avatar" />
<AvatarFallback>
{{ getInitials(props.initialValues.first_name, props.initialValues.last_name) }}
</AvatarFallback>
</Avatar>
<div class="space-y-4 flex-2">
<div class="flex items-center gap-3">
<h3 class="text-lg font-semibold text-gray-900 dark:text-foreground">
{{ props.initialValues.first_name }} {{ props.initialValues.last_name }}
</h3>
<Badge :class="['px-2 rounded-full text-xs font-medium', availabilityStatus.color]">
{{ availabilityStatus.text }}
</Badge>
</div>
<div class="flex flex-wrap items-center gap-6">
<div class="flex items-center gap-2">
<Clock class="w-5 h-5 text-gray-400" />
<div>
<p class="text-sm text-gray-500">{{ $t('globals.terms.lastActive') }}</p>
<p class="text-sm font-medium text-gray-700 dark:text-foreground">
{{
props.initialValues.last_active_at
? format(new Date(props.initialValues.last_active_at), 'PPpp')
: 'N/A'
}}
</p>
</div>
</div>
<div class="flex items-center gap-2">
<LogIn class="w-5 h-5 text-gray-400" />
<div>
<p class="text-sm text-gray-500">{{ $t('globals.terms.lastLogin') }}</p>
<p class="text-sm font-medium text-gray-700 dark:text-foreground">
{{
props.initialValues.last_login_at
? format(new Date(props.initialValues.last_login_at), 'PPpp')
: 'N/A'
}}
</p>
</div>
</div>
</div>
</div>
</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" />
<Button
type="button"
variant="outline"
size="sm"
@click="copyToClipboard(newAPIKeyData.api_key)"
>
<Copy class="w-4 h-4" />
</Button>
</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" />
<Button
type="button"
variant="outline"
size="sm"
@click="copyToClipboard(newAPIKeyData.api_secret)"
>
<Copy class="w-4 h-4" />
</Button>
</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>
<FormLabel>{{ $t('globals.terms.firstName') }}</FormLabel>
<FormControl>
<Input type="text" placeholder="" v-bind="field" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ field }" name="last_name">
<FormItem>
<FormLabel>{{ $t('globals.terms.lastName') }}</FormLabel>
<FormControl>
<Input type="text" placeholder="" v-bind="field" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ field }" name="email">
<FormItem v-auto-animate>
<FormLabel>{{ $t('globals.terms.email') }}</FormLabel>
<FormControl>
<Input type="email" placeholder="" v-bind="field" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField, handleChange }" name="teams">
<FormItem v-auto-animate>
<FormLabel>{{ $t('globals.terms.team', 2) }}</FormLabel>
<FormControl>
<SelectTag
:items="teamOptions"
:placeholder="t('globals.messages.select', { name: t('globals.terms.team', 2) })"
v-model="componentField.modelValue"
@update:modelValue="handleChange"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField, handleChange }" name="roles">
<FormItem v-auto-animate>
<FormLabel>{{ $t('globals.terms.role', 2) }}</FormLabel>
<FormControl>
<SelectTag
:items="roleOptions"
:placeholder="
t('globals.messages.select', {
name: $t('globals.terms.role', 2)
})
"
v-model="componentField.modelValue"
@update:modelValue="handleChange"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="availability_status" v-if="!isNewForm">
<FormItem>
<FormLabel>{{ t('globals.terms.availabilityStatus') }}</FormLabel>
<FormControl>
<Select v-bind="componentField" v-model="componentField.modelValue">
<SelectTrigger>
<SelectValue
:placeholder="
t('globals.messages.select', {
name: t('globals.terms.availabilityStatus')
})
"
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="active_group">{{ t('globals.terms.active') }}</SelectItem>
<SelectItem value="away_manual">{{ t('globals.terms.away') }}</SelectItem>
<SelectItem value="away_and_reassigning">
{{ t('globals.terms.awayReassigning') }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ field }" name="new_password" v-if="!isNewForm">
<FormItem v-auto-animate>
<FormLabel>{{ t('globals.terms.setPassword') }}</FormLabel>
<FormControl>
<Input type="password" placeholder="" v-bind="field" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField name="send_welcome_email" v-slot="{ value, handleChange }" v-if="isNewForm">
<FormItem>
<FormControl>
<div class="flex items-center space-x-2">
<Checkbox :checked="value" @update:checked="handleChange" />
<Label>{{ $t('globals.terms.sendWelcomeEmail') }}</Label>
</div>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ value, handleChange }" type="checkbox" name="enabled" v-if="!isNewForm">
<FormItem class="flex flex-row items-start gap-x-3 space-y-0">
<FormControl>
<Checkbox :checked="value" @update:checked="handleChange" />
</FormControl>
<div class="space-y-1 leading-none">
<FormLabel> {{ $t('globals.terms.enabled') }} </FormLabel>
<FormMessage />
</div>
</FormItem>
</FormField>
<Button type="submit" :isLoading="isLoading"> {{ submitLabel }} </Button>
</form>
</template>
<script setup>
import { watch, onMounted, ref, computed } from 'vue'
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 '@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 '@shared-ui/components/ui/badge/index.js'
import { Clock, LogIn, Key, RotateCcw, Trash2, Plus, Copy, AlertTriangle } from 'lucide-vue-next'
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@shared-ui/components/ui/form/index.js'
import { Avatar, AvatarFallback, AvatarImage } from '@shared-ui/components/ui/avatar/index.js'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
} 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/index.js'
const props = defineProps({
initialValues: {
type: Object,
required: false
},
submitForm: {
type: Function,
required: true
},
submitLabel: {
type: String,
required: false,
default: 'Submit'
},
isNewForm: {
type: Boolean,
required: false,
default: false
},
isLoading: {
Type: Boolean,
required: false
}
})
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 {
const [teamsResp, rolesResp] = await Promise.allSettled([api.getTeams(), api.getRoles()])
teams.value = teamsResp.value.data.data
roles.value = rolesResp.value.data.data
} catch (err) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: t('globals.messages.errorFetching')
})
}
})
const availabilityStatus = computed(() => {
const status = form.values.availability_status
if (status === 'active_group') return { text: t('globals.terms.active'), color: 'bg-green-500' }
if (status === 'away_manual') return { text: t('globals.terms.away'), color: 'bg-yellow-500' }
if (status === 'away_and_reassigning')
return { text: t('globals.terms.awayReassigning'), color: 'bg-orange-500' }
return { text: t('globals.terms.offline'), color: 'bg-gray-400' }
})
const teamOptions = computed(() =>
teams.value.map((team) => ({ label: team.name, value: team.name }))
)
const roleOptions = computed(() =>
roles.value.map((role) => ({ label: role.name, value: role.name }))
)
const form = useForm({
validationSchema: toTypedSchema(createFormSchema(t))
})
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)
})
const getInitials = (firstName, lastName) => {
if (!firstName && !lastName) return ''
if (!firstName) return lastName.charAt(0).toUpperCase()
if (!lastName) return firstName.charAt(0).toUpperCase()
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 copyToClipboard = async (text) => {
try {
await navigator.clipboard.writeText(text)
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
description: t('globals.messages.copied')
})
} catch (error) {
console.error('Error copying to clipboard:', error)
}
}
const closeAPIKeyModal = () => {
showAPIKeyDialog.value = false
newAPIKeyData.value = { api_key: '', api_secret: '' }
}
watch(
() => props.initialValues,
(newValues) => {
// Hack.
if (Object.keys(newValues).length > 0) {
setTimeout(() => {
if (
newValues.availability_status === 'away' ||
newValues.availability_status === 'offline' ||
newValues.availability_status === 'online'
) {
newValues.availability_status = 'active_group'
}
form.setValues(newValues)
form.setFieldValue(
'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)
}
},
{ deep: true, immediate: true }
)
</script>

View File

@@ -6,7 +6,7 @@ export const createColumns = (t) => [
{
accessorKey: 'first_name',
header: function () {
return h('div', { class: 'text-center' }, t('form.field.firstName'))
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'))
@@ -15,7 +15,7 @@ export const createColumns = (t) => [
{
accessorKey: 'last_name',
header: function () {
return h('div', { class: 'text-center' }, t('form.field.lastName'))
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'))
@@ -24,7 +24,7 @@ export const createColumns = (t) => [
{
accessorKey: 'enabled',
header: function () {
return h('div', { class: 'text-center' }, t('form.field.enabled'))
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'))
@@ -33,7 +33,7 @@ export const createColumns = (t) => [
{
accessorKey: 'email',
header: function () {
return h('div', { class: 'text-center' }, t('form.field.email'))
return h('div', { class: 'text-center' }, t('globals.terms.email'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('email'))
@@ -42,7 +42,7 @@ export const createColumns = (t) => [
{
accessorKey: 'created_at',
header: function () {
return h('div', { class: 'text-center' }, t('form.field.createdAt'))
return h('div', { class: 'text-center' }, t('globals.terms.createdAt'))
},
cell: function ({ row }) {
return h(
@@ -55,7 +55,7 @@ export const createColumns = (t) => [
{
accessorKey: 'updated_at',
header: function () {
return h('div', { class: 'text-center' }, t('form.field.updatedAt'))
return h('div', { class: 'text-center' }, t('globals.terms.updatedAt'))
},
cell: function ({ row }) {
return h(

View File

@@ -8,10 +8,10 @@
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="editUser(props.user.id)">{{
$t('globals.buttons.edit')
$t('globals.messages.edit')
}}</DropdownMenuItem>
<DropdownMenuItem @click="() => (alertOpen = true)">{{
$t('globals.buttons.delete')
$t('globals.messages.delete')
}}</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -20,12 +20,12 @@
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{{ $t('globals.messages.areYouAbsolutelySure') }}</AlertDialogTitle>
<AlertDialogDescription>{{ $t('admin.user.deleteConfirmation') }}</AlertDialogDescription>
<AlertDialogDescription>{{ $t('admin.agent.deleteConfirmation') }}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{{ $t('globals.buttons.cancel') }}</AlertDialogCancel>
<AlertDialogCancel>{{ $t('globals.messages.cancel') }}</AlertDialogCancel>
<AlertDialogAction @click="handleDelete">{{
$t('globals.buttons.delete')
$t('globals.messages.delete')
}}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -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

@@ -32,18 +32,22 @@ export const createFormSchema = (t) => z.object({
teams: z.array(z.string()).default([]),
roles: z.array(z.string()).min(1, t('globals.messages.pleaseSelectAtLeastOne', {
roles: z.array(z.string()).min(1, t('globals.messages.selectAtLeastOne', {
name: t('globals.terms.role')
})),
new_password: z
.string()
.regex(/^(?=.*[A-Z])(?=.*[a-z])(?=.*\d)(?=.*[\W_]).{10,72}$/, {
message: t('globals.messages.strongPassword', {
min: 10,
max: 72,
})
.min(10, {
message: t('globals.messages.strongPassword', { min: 10, max: 72 })
})
.max(72, {
message: t('globals.messages.strongPassword', { min: 10, max: 72 })
})
.refine(val => /[a-z]/.test(val), t('globals.messages.strongPassword', { min: 10, max: 72 }))
.refine(val => /[A-Z]/.test(val), t('globals.messages.strongPassword', { min: 10, max: 72 }))
.refine(val => /\d/.test(val), t('globals.messages.strongPassword', { min: 10, max: 72 }))
.refine(val => /[\W_]/.test(val), t('globals.messages.strongPassword', { min: 10, max: 72 }))
.optional(),
enabled: z.boolean().optional().default(true),
availability_status: z.string().optional().default('offline'),

View File

@@ -0,0 +1,378 @@
import { describe, test, expect } from 'vitest'
import { createFormSchema } from './formSchema'
const mockT = (key, params) => `${key} ${JSON.stringify(params || {})}`
const schema = createFormSchema(mockT)
const validForm = {
first_name: 'John',
email: 'test@test.com',
roles: ['admin'],
new_password: 'Password123!'
}
describe('Form Schema', () => {
// Valid cases
test('valid complete form', () => {
expect(() => schema.parse(validForm)).not.toThrow()
})
test('valid minimal form', () => {
expect(() => schema.parse({
first_name: 'Jo',
email: 'a@b.co',
roles: ['user']
})).not.toThrow()
})
// First name tests
test('first_name too short', () => {
expect(() => schema.parse({ ...validForm, first_name: 'J' })).toThrow()
})
test('first_name too long', () => {
expect(() => schema.parse({ ...validForm, first_name: 'a'.repeat(51) })).toThrow()
})
test('first_name missing', () => {
const { first_name, ...form } = validForm
expect(() => schema.parse(form)).toThrow()
})
test('first_name empty string', () => {
expect(() => schema.parse({ ...validForm, first_name: '' })).toThrow()
})
test('first_name null', () => {
expect(() => schema.parse({ ...validForm, first_name: null })).toThrow()
})
// Email tests
test('invalid email format', () => {
expect(() => schema.parse({ ...validForm, email: 'invalid' })).toThrow()
})
test('email missing @', () => {
expect(() => schema.parse({ ...validForm, email: 'test.com' })).toThrow()
})
test('email missing domain', () => {
expect(() => schema.parse({ ...validForm, email: 'test@' })).toThrow()
})
test('email empty', () => {
expect(() => schema.parse({ ...validForm, email: '' })).toThrow()
})
test('email missing', () => {
const { email, ...form } = validForm
expect(() => schema.parse(form)).toThrow()
})
// Roles tests
test('roles empty array', () => {
expect(() => schema.parse({ ...validForm, roles: [] })).toThrow()
})
test('roles missing', () => {
const { roles, ...form } = validForm
expect(() => schema.parse(form)).toThrow()
})
test('roles multiple values', () => {
expect(() => schema.parse({ ...validForm, roles: ['admin', 'user', 'moderator'] })).not.toThrow()
})
// Password tests
test('password too short', () => {
expect(() => schema.parse({ ...validForm, new_password: 'Pass1!' })).toThrow()
})
test('password too long', () => {
expect(() => schema.parse({ ...validForm, new_password: 'P'.repeat(73) + 'a1!' })).toThrow()
})
test('password missing uppercase', () => {
expect(() => schema.parse({ ...validForm, new_password: 'password123!' })).toThrow()
})
test('password missing lowercase', () => {
expect(() => schema.parse({ ...validForm, new_password: 'PASSWORD123!' })).toThrow()
})
test('password missing number', () => {
expect(() => schema.parse({ ...validForm, new_password: 'Password!@#$' })).toThrow()
})
test('password missing special char', () => {
expect(() => schema.parse({ ...validForm, new_password: 'Password123' })).toThrow()
})
test('password only special chars', () => {
expect(() => schema.parse({ ...validForm, new_password: '!@#$%^&*()' })).toThrow()
})
test('password unicode special chars', () => {
expect(() => schema.parse({ ...validForm, new_password: 'Password123ñ' })).not.toThrow()
})
test('password underscore as special char', () => {
expect(() => schema.parse({ ...validForm, new_password: 'Password123_' })).not.toThrow()
})
test('password exactly 10 chars', () => {
expect(() => schema.parse({ ...validForm, new_password: 'Password1!' })).not.toThrow()
})
test('password exactly 72 chars', () => {
expect(() => schema.parse({ ...validForm, new_password: 'P'.repeat(69) + 'a1!' })).not.toThrow()
})
// Optional fields
test('last_name optional', () => {
expect(() => schema.parse(validForm)).not.toThrow()
expect(() => schema.parse({ ...validForm, last_name: 'Doe' })).not.toThrow()
expect(() => schema.parse({ ...validForm, last_name: '' })).not.toThrow()
})
test('send_welcome_email optional', () => {
expect(() => schema.parse({ ...validForm, send_welcome_email: true })).not.toThrow()
expect(() => schema.parse({ ...validForm, send_welcome_email: false })).not.toThrow()
})
test('enabled defaults to true', () => {
const result = schema.parse(validForm)
expect(result.enabled).toBe(true)
})
test('availability_status defaults to offline', () => {
const result = schema.parse(validForm)
expect(result.availability_status).toBe('offline')
})
test('teams defaults to empty array', () => {
const result = schema.parse(validForm)
expect(result.teams).toEqual([])
})
test('teams with values', () => {
expect(() => schema.parse({ ...validForm, teams: ['team1', 'team2'] })).not.toThrow()
})
// Edge cases
test('undefined values', () => {
expect(() => schema.parse({
first_name: undefined,
email: 'test@test.com',
roles: ['admin']
})).toThrow()
})
test('null values', () => {
expect(() => schema.parse({
first_name: null,
email: 'test@test.com',
roles: ['admin']
})).toThrow()
})
test('number as string field', () => {
expect(() => schema.parse({ ...validForm, first_name: 123 })).toThrow()
})
test('string as boolean field', () => {
expect(() => schema.parse({ ...validForm, enabled: 'true' })).toThrow()
})
test('string as array field', () => {
expect(() => schema.parse({ ...validForm, roles: 'admin' })).toThrow()
})
test('empty object', () => {
expect(() => schema.parse({})).toThrow()
})
test('extra unknown fields ignored', () => {
expect(() => schema.parse({
...validForm,
unknown_field: 'value',
another_field: 123
})).not.toThrow()
})
})
// Password regex validation tests
describe('Password Regex Validation', () => {
// Lowercase tests
test('lowercase - single letter', () => {
expect(() => schema.parse({ ...validForm, new_password: 'PASSWORD123!a' })).not.toThrow()
})
test('lowercase - multiple letters', () => {
expect(() => schema.parse({ ...validForm, new_password: 'PASSWORDabc123!' })).not.toThrow()
})
test('lowercase - accented characters', () => {
expect(() => schema.parse({ ...validForm, new_password: 'PASSWORD123!ñ' })).toThrow()
})
test('lowercase - none', () => {
expect(() => schema.parse({ ...validForm, new_password: 'PASSWORD123!' })).toThrow()
})
// Uppercase tests
test('uppercase - single letter', () => {
expect(() => schema.parse({ ...validForm, new_password: 'passwordA123!' })).not.toThrow()
})
test('uppercase - multiple letters', () => {
expect(() => schema.parse({ ...validForm, new_password: 'passwordABC123!' })).not.toThrow()
})
test('uppercase - accented characters', () => {
expect(() => schema.parse({ ...validForm, new_password: 'passwordÑ123!' })).toThrow()
})
test('uppercase - none', () => {
expect(() => schema.parse({ ...validForm, new_password: 'password123!' })).toThrow()
})
// Digit tests
test('digit - single number', () => {
expect(() => schema.parse({ ...validForm, new_password: 'Password1!' })).not.toThrow()
})
test('digit - multiple numbers', () => {
expect(() => schema.parse({ ...validForm, new_password: 'Password123!' })).not.toThrow()
})
test('digit - zero', () => {
expect(() => schema.parse({ ...validForm, new_password: 'Password0!' })).not.toThrow()
})
test('digit - none', () => {
expect(() => schema.parse({ ...validForm, new_password: 'Password!' })).toThrow()
})
// Special character tests
test('special - common symbols', () => {
expect(() => schema.parse({ ...validForm, new_password: 'Password123!' })).not.toThrow()
expect(() => schema.parse({ ...validForm, new_password: 'Password123@' })).not.toThrow()
expect(() => schema.parse({ ...validForm, new_password: 'Password123#' })).not.toThrow()
expect(() => schema.parse({ ...validForm, new_password: 'Password123$' })).not.toThrow()
expect(() => schema.parse({ ...validForm, new_password: 'Password123%' })).not.toThrow()
expect(() => schema.parse({ ...validForm, new_password: 'Password123^' })).not.toThrow()
expect(() => schema.parse({ ...validForm, new_password: 'Password123&' })).not.toThrow()
expect(() => schema.parse({ ...validForm, new_password: 'Password123*' })).not.toThrow()
})
test('special - brackets and parentheses', () => {
expect(() => schema.parse({ ...validForm, new_password: 'Password123(' })).not.toThrow()
expect(() => schema.parse({ ...validForm, new_password: 'Password123)' })).not.toThrow()
expect(() => schema.parse({ ...validForm, new_password: 'Password123[' })).not.toThrow()
expect(() => schema.parse({ ...validForm, new_password: 'Password123]' })).not.toThrow()
expect(() => schema.parse({ ...validForm, new_password: 'Password123{' })).not.toThrow()
expect(() => schema.parse({ ...validForm, new_password: 'Password123}' })).not.toThrow()
})
test('special - punctuation', () => {
expect(() => schema.parse({ ...validForm, new_password: 'Password123.' })).not.toThrow()
expect(() => schema.parse({ ...validForm, new_password: 'Password123,' })).not.toThrow()
expect(() => schema.parse({ ...validForm, new_password: 'Password123;' })).not.toThrow()
expect(() => schema.parse({ ...validForm, new_password: 'Password123:' })).not.toThrow()
expect(() => schema.parse({ ...validForm, new_password: 'Password123?' })).not.toThrow()
expect(() => schema.parse({ ...validForm, new_password: 'Password123/' })).not.toThrow()
})
test('special - quotes and backslash', () => {
expect(() => schema.parse({ ...validForm, new_password: "Password123'" })).not.toThrow()
expect(() => schema.parse({ ...validForm, new_password: 'Password123"' })).not.toThrow()
expect(() => schema.parse({ ...validForm, new_password: 'Password123\\' })).not.toThrow()
expect(() => schema.parse({ ...validForm, new_password: 'Password123|' })).not.toThrow()
})
test('special - math symbols', () => {
expect(() => schema.parse({ ...validForm, new_password: 'Password123+' })).not.toThrow()
expect(() => schema.parse({ ...validForm, new_password: 'Password123-' })).not.toThrow()
expect(() => schema.parse({ ...validForm, new_password: 'Password123=' })).not.toThrow()
expect(() => schema.parse({ ...validForm, new_password: 'Password123<' })).not.toThrow()
expect(() => schema.parse({ ...validForm, new_password: 'Password123>' })).not.toThrow()
})
test('special - underscore', () => {
expect(() => schema.parse({ ...validForm, new_password: 'Password123_' })).not.toThrow()
})
test('special - space', () => {
expect(() => schema.parse({ ...validForm, new_password: 'Password123 ' })).not.toThrow()
})
test('special - none', () => {
expect(() => schema.parse({ ...validForm, new_password: 'Password123' })).toThrow()
})
// Combination edge cases
test('only uppercase and special', () => {
expect(() => schema.parse({ ...validForm, new_password: 'PASSWORD!@#$%^&*()' })).toThrow()
})
test('only lowercase and digits', () => {
expect(() => schema.parse({ ...validForm, new_password: 'password123456' })).toThrow()
})
test('whitespace only special char', () => {
expect(() => schema.parse({ ...validForm, new_password: 'Password123 ' })).not.toThrow()
})
test('tab as special char', () => {
expect(() => schema.parse({ ...validForm, new_password: 'Password123\t' })).not.toThrow()
})
test('newline as special char', () => {
expect(() => schema.parse({ ...validForm, new_password: 'Password123\n' })).not.toThrow()
})
})
// Password validation - passing cases
describe('Password Valid Cases', () => {
test('exact minimum length with all requirements', () => {
expect(() => schema.parse({ ...validForm, new_password: 'Password1!' })).not.toThrow()
})
test('exact maximum length with all requirements', () => {
expect(() => schema.parse({ ...validForm, new_password: 'P'.repeat(67) + 'ass1!' })).not.toThrow()
})
test('multiple of each requirement', () => {
expect(() => schema.parse({ ...validForm, new_password: 'PASSWORDpassword123456!@#$%^&*()' })).not.toThrow()
})
test('mixed case throughout', () => {
expect(() => schema.parse({ ...validForm, new_password: 'PaSSwoRD123!@#' })).not.toThrow()
})
test('numbers at start', () => {
expect(() => schema.parse({ ...validForm, new_password: '123Password!' })).not.toThrow()
})
test('special chars at start', () => {
expect(() => schema.parse({ ...validForm, new_password: '!@#Password123' })).not.toThrow()
})
test('all character types mixed', () => {
expect(() => schema.parse({ ...validForm, new_password: 'P@ssw0rd123!' })).not.toThrow()
})
test('unicode characters', () => {
expect(() => schema.parse({ ...validForm, new_password: 'Påssw0rd123!' })).not.toThrow()
})
test('long valid password', () => {
expect(() => schema.parse({ ...validForm, new_password: 'ThisIsAVeryLongPasswordWith123!SpecialChars' })).not.toThrow()
})
test('password with spaces', () => {
expect(() => schema.parse({ ...validForm, new_password: 'Pass Word 123!' })).not.toThrow()
})
})

View File

@@ -1,5 +1,5 @@
<template>
<div class="space-y-5 rounded-lg" :class="{ 'box p-5': actions.length > 0 }">
<div class="space-y-5 rounded" :class="{ 'box p-5': actions.length > 0 }">
<div class="space-y-5">
<div v-for="(action, index) in actions" :key="index" class="space-y-5">
<div v-if="index > 0">
@@ -16,7 +16,7 @@
@update:modelValue="(value) => handleFieldChange(value, index)"
>
<SelectTrigger class="m-auto">
<SelectValue :placeholder="t('form.field.selectAction')" />
<SelectValue :placeholder="t('globals.messages.select', { name: t('globals.terms.action').toLowerCase() })" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
@@ -40,7 +40,7 @@
<SelectTag
v-model="action.value"
:items="tagsStore.tagNames.map((tag) => ({ label: tag, value: tag }))"
:placeholder="t('form.field.selectTag')"
:placeholder="t('globals.messages.select', { name: t('globals.terms.tag', 2).toLowerCase() })"
/>
</div>
@@ -48,63 +48,17 @@
class="w-48"
v-if="action.type && conversationActions[action.type]?.type === 'select'"
>
<ComboBox
<SelectComboBox
v-model="action.value[0]"
:items="conversationActions[action.type]?.options"
:placeholder="t('form.field.select')"
:placeholder="t('globals.messages.select', { name: '' })"
@select="handleValueChange($event, index)"
>
<template #item="{ item }">
<div class="flex items-center gap-2 ml-2">
<Avatar v-if="action.type === 'assign_user'" class="w-7 h-7">
<AvatarImage :src="item.avatar_url || ''" :alt="item.label.slice(0, 2)" />
<AvatarFallback>
{{ item.label.slice(0, 2).toUpperCase() }}
</AvatarFallback>
</Avatar>
<span v-if="action.type === 'assign_team'">
{{ item.emoji }}
</span>
<span>{{ item.label }}</span>
</div>
</template>
<template #selected="{ selected }">
<div v-if="action.type === 'assign_team'">
<div v-if="selected" class="flex items-center gap-2">
{{ selected.emoji }}
<span>{{ selected.label }}</span>
</div>
<span v-else>{{ $t('form.field.selectTeam') }}</span>
</div>
<div v-else-if="action.type === 'assign_user'" class="flex items-center gap-2">
<div v-if="selected" class="flex items-center gap-2">
<Avatar class="w-7 h-7">
<AvatarImage
:src="selected.avatar_url ?? ''"
:alt="selected.label.slice(0, 2)"
/>
<AvatarFallback>
{{ selected.label.slice(0, 2).toUpperCase() }}
</AvatarFallback>
</Avatar>
<span>{{ selected.label }}</span>
</div>
<span v-else>{{ $t('form.field.selectUser') }}</span>
</div>
<span v-else>
<span v-if="!selected"> {{ $t('form.field.select') }}</span>
<span v-else>{{ selected.label }} </span>
</span>
</template>
</ComboBox>
:type="action.type === 'assign_team' ? 'team' : 'user'"
/>
</div>
</div>
<div class="cursor-pointer" @click.prevent="removeAction(index)">
<X size="16" />
</div>
<CloseButton :onClose="() => removeAction(index)" />
</div>
<div
@@ -112,9 +66,10 @@
v-if="action.type && conversationActions[action.type]?.type === 'richtext'"
>
<Editor
:autoFocus="false"
v-model:htmlContent="action.value[0]"
@update:htmlContent="(value) => handleEditorChange(value, index)"
:placeholder="t('editor.placeholder')"
:placeholder="t('editor.newLine') + t('editor.send') + t('editor.ctrlK')"
/>
</div>
</div>
@@ -132,9 +87,9 @@
<script setup>
import { toRefs } from 'vue'
import { Button } from '@/components/ui/button'
import { X } from 'lucide-vue-next'
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,
@@ -142,14 +97,13 @@ import {
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { SelectTag } from '@/components/ui/select'
import { useConversationFilters } from '@/composables/useConversationFilters'
import { getTextFromHTML } from '@/utils/strings.js'
} from '@shared-ui/components/ui/select'
import { SelectTag } from '@shared-ui/components/ui/select'
import { useConversationFilters } from '../../../composables/useConversationFilters'
import { getTextFromHTML } from '../../../utils/strings.js'
import { useI18n } from 'vue-i18n'
import Editor from '@/features/conversation/ConversationTextEditor.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

@@ -23,7 +23,7 @@
</RadioGroup>
</div>
<div class="space-y-5 rounded-lg" :class="{ 'box p-5': ruleGroup.rules?.length > 0 }">
<div class="space-y-5 rounded" :class="{ 'box p-5': ruleGroup.rules?.length > 0 }">
<div class="space-y-5">
<div v-for="(rule, index) in ruleGroup.rules" :key="rule" class="space-y-5">
<div v-if="index > 0">
@@ -37,7 +37,7 @@
@update:modelValue="(value) => handleFieldChange(value, index)"
>
<SelectTrigger class="w-56">
<SelectValue :placeholder="t('form.field.selectField')" />
<SelectValue :placeholder="t('globals.messages.select', { name: t('globals.terms.field').toLowerCase() })" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
@@ -65,7 +65,7 @@
@update:modelValue="(value) => handleOperatorChange(value, index)"
>
<SelectTrigger class="w-56">
<SelectValue :placeholder="t('form.field.selectOperator')" />
<SelectValue :placeholder="t('globals.messages.select', { name: t('globals.terms.operator').toLowerCase() })" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
@@ -85,7 +85,7 @@
<!-- Plain text input -->
<Input
type="text"
:placeholder="t('form.field.setValue')"
:placeholder="t('globals.messages.set', { name: t('globals.terms.value').toLowerCase() })"
v-if="inputType(index) === 'text'"
v-model="rule.value"
@update:modelValue="(value) => handleValueChange(value, index)"
@@ -94,7 +94,7 @@
<!-- Number input -->
<Input
type="number"
:placeholder="t('form.field.setValue')"
:placeholder="t('globals.messages.set', { name: t('globals.terms.value').toLowerCase() })"
v-if="inputType(index) === 'number'"
v-model="rule.value"
@update:modelValue="(value) => handleValueChange(value, index)"
@@ -102,59 +102,12 @@
<!-- Select input -->
<div v-if="inputType(index) === 'select'">
<ComboBox
<SelectComboBox
v-model="rule.value"
:items="getFieldOptions(rule.field, rule.field_type)"
@select="handleValueChange($event, index)"
>
<template #item="{ item }">
<div class="flex items-center gap-2 ml-2">
<Avatar v-if="rule.field === 'assigned_user'" class="w-7 h-7">
<AvatarImage :src="item.avatar_url || ''" :alt="item.label.slice(0, 2)" />
<AvatarFallback>
{{ item.label.slice(0, 2).toUpperCase() }}
</AvatarFallback>
</Avatar>
<span v-if="rule.field === 'assigned_team'">
{{ item.emoji }}
</span>
<span>{{ item.label }}</span>
</div>
</template>
<template #selected="{ selected }">
<div v-if="rule?.field === 'assigned_team'">
<div v-if="selected" class="flex items-center gap-2">
{{ selected.emoji }}
<span>{{ selected.label }}</span>
</div>
<span v-else>{{ $t('form.field.selectTeam') }}</span>
</div>
<div
v-else-if="rule?.field === 'assigned_user'"
class="flex items-center gap-2"
>
<div v-if="selected" class="flex items-center gap-2">
<Avatar class="w-7 h-7">
<AvatarImage
:src="selected.avatar_url || ''"
:alt="selected.label.slice(0, 2)"
/>
<AvatarFallback>
{{ selected.label.slice(0, 2).toUpperCase() }}
</AvatarFallback>
</Avatar>
<span>{{ selected.label }}</span>
</div>
<span v-else>{{ $t('form.field.selectUser') }}</span>
</div>
<span v-else>
<span v-if="!selected"> {{ $t('form.field.select') }}</span>
<span v-else>{{ selected.label }} </span>
</span>
</template>
</ComboBox>
:type="rule.field === 'assigned_user' ? 'user' : 'team'"
/>
</div>
<!-- Tag input -->
@@ -171,7 +124,7 @@
<TagsInputItemText />
<TagsInputItemDelete />
</TagsInputItem>
<TagsInputInput :placeholder="t('form.field.selectValue')" />
<TagsInputInput :placeholder="t('globals.messages.select', { name: t('globals.terms.value').toLowerCase() })" />
</TagsInput>
<p class="text-xs text-gray-500 mt-1">
{{ $t('globals.messages.pressEnterToSelectAValue') }}
@@ -181,7 +134,7 @@
<!-- Date input -->
<Input
type="date"
:placeholder="t('form.field.setValue')"
:placeholder="t('globals.messages.set', { name: t('globals.terms.value').toLowerCase() })"
v-if="inputType(index) === 'date'"
v-model="rule.value"
@update:modelValue="(value) => handleValueChange(value, index)"
@@ -194,7 +147,7 @@
v-if="inputType(index) === 'boolean'"
>
<SelectTrigger>
<SelectValue :placeholder="t('form.field.selectValue')" />
<SelectValue :placeholder="t('globals.messages.select', { name: t('globals.terms.value').toLowerCase() })" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
@@ -209,9 +162,7 @@
<div v-else class="flex-1"></div>
<!-- Remove condition -->
<div class="cursor-pointer mt-2" @click.prevent="removeCondition(index)">
<X size="16" />
</div>
<CloseButton :onClose="() => removeCondition(index)" />
</div>
<div class="flex items-center space-x-2">
@@ -239,9 +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 { 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,
@@ -250,21 +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 { X } from 'lucide-vue-next'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
} 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 { useConversationFilters } from '../../../composables/useConversationFilters'
import SelectComboBox from '@main/components/combobox/SelectCombobox.vue'
const props = defineProps({
ruleGroup: {

View File

@@ -7,8 +7,8 @@
{{ rule.name }}
</div>
<div class="mb-1">
<Badge v-if="rule.enabled" class="text-[9px]">{{ $t('form.field.enabled') }}</Badge>
<Badge v-else variant="secondary">{{ $t('form.field.disabled') }}</Badge>
<Badge v-if="rule.enabled" class="text-[9px]">{{ $t('globals.terms.enabled') }}</Badge>
<Badge v-else variant="secondary">{{ $t('globals.terms.disabled') }}</Badge>
</div>
</span>
</div>
@@ -21,16 +21,16 @@
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="navigateToEditRule(rule.id)">
<span>{{ $t('globals.buttons.edit') }}</span>
<span>{{ $t('globals.messages.edit') }}</span>
</DropdownMenuItem>
<DropdownMenuItem @click="() => (alertOpen = true)">
<span>{{ $t('globals.buttons.delete') }}</span>
<span>{{ $t('globals.messages.delete') }}</span>
</DropdownMenuItem>
<DropdownMenuItem @click="$emit('toggle-rule', rule.id)" v-if="rule.enabled">
<span>{{ $t('globals.buttons.disable') }}</span>
<span>{{ $t('globals.messages.disable') }}</span>
</DropdownMenuItem>
<DropdownMenuItem @click="$emit('toggle-rule', rule.id)" v-else>
<span>{{ $t('globals.buttons.enable') }}</span>
<span>{{ $t('globals.messages.enable') }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -44,13 +44,17 @@
<AlertDialogHeader>
<AlertDialogTitle>{{ $t('globals.messages.areYouAbsolutelySure') }}</AlertDialogTitle>
<AlertDialogDescription>
{{ $t('admin.automation.deleteConfirmation') }}
{{
$t('globals.messages.deletionConfirmation', {
name: $t('globals.terms.automationRule').toLowerCase()
})
}}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{{ $t('globals.buttons.cancel') }}</AlertDialogCancel>
<AlertDialogCancel>{{ $t('globals.messages.cancel') }}</AlertDialogCancel>
<AlertDialogAction @click="handleDelete">{{
$t('globals.buttons.delete')
$t('globals.messages.delete')
}}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -64,7 +68,7 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
} from '@shared-ui/components/ui/dropdown-menu'
import {
AlertDialog,
AlertDialogAction,
@@ -74,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

@@ -23,6 +23,21 @@
</Select>
</div>
<div
v-if="!isLoading && rules.length === 0"
class="flex flex-col items-center justify-center py-12 px-4"
>
<div class="text-center space-y-2">
<p class="text-muted-foreground">
{{
$t('globals.messages.noResults', {
name: $t('globals.terms.rule', 2).toLowerCase()
})
}}
</p>
</div>
</div>
<div class="space-y-4">
<div v-if="type === 'new_conversation'">
<draggable v-model="rules" class="space-y-5" item-key="id" @end="onDragEnd">
@@ -49,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

@@ -18,7 +18,7 @@ export const createFormSchema = (t) => z
if (data.type === 'conversation_update' && (!data.events || data.events.length === 0)) {
ctx.addIssue({
path: ['events'],
message: t('globals.messages.pleaseSelectAtLeastOne', {
message: t('globals.messages.selectAtLeastOne', {
name: t('globals.terms.event')
}),
code: z.ZodIssueCode.custom,

View File

@@ -3,7 +3,7 @@
<FormField v-slot="{ componentField }" name="name">
<FormItem>
<FormLabel>
{{ t('form.field.name') }}
{{ t('globals.terms.name') }}
</FormLabel>
<FormControl>
<Input type="text" placeholder="" v-bind="componentField" />
@@ -15,7 +15,7 @@
<FormField v-slot="{ componentField }" name="description">
<FormItem>
<FormLabel>
{{ t('form.field.description') }}
{{ t('globals.terms.description') }}
</FormLabel>
<FormControl>
<Input type="text" placeholder="" v-bind="componentField" />
@@ -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">
@@ -106,7 +106,7 @@
</div>
</div>
<SimpleTable
:headers="[t('form.field.name'), t('form.field.date')]"
:headers="[t('globals.terms.name'), t('globals.terms.date')]"
:keys="['name', 'date']"
:data="holidays"
@deleteItem="deleteHoliday"
@@ -124,11 +124,11 @@
</DialogHeader>
<div class="grid gap-4 py-4">
<div class="grid grid-cols-4 items-center gap-4">
<Label for="holiday_name" class="text-right"> {{ t('form.field.name') }} </Label>
<Label for="holiday_name" class="text-right"> {{ t('globals.terms.name') }} </Label>
<Input id="holiday_name" v-model="holidayName" class="col-span-3" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="date" class="text-right"> {{ t('form.field.date') }} </Label>
<Label for="date" class="text-right"> {{ t('globals.terms.date') }} </Label>
<Popover>
<PopoverTrigger as-child>
<Button
@@ -144,7 +144,7 @@
{{
holidayDate && !isNaN(new Date(holidayDate).getTime())
? format(new Date(holidayDate), 'MMMM dd, yyyy')
: t('form.field.pickDate')
: t('globals.terms.pickDate')
}}
</Button>
</PopoverTrigger>
@@ -156,7 +156,7 @@
</div>
<DialogFooter>
<Button :disabled="!holidayName || !holidayDate" @click="saveHoliday">
{{ t('globals.buttons.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: {
@@ -218,7 +218,7 @@ const props = defineProps({
})
const submitLabel = computed(() => {
return props.submitLabel || t('globals.buttons.save')
return props.submitLabel || t('globals.messages.save')
})
let holidays = reactive([])
@@ -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

@@ -6,7 +6,7 @@ export const createColumns = (t) => [
{
accessorKey: 'name',
header: function () {
return h('div', { class: 'text-center' }, t('form.field.name'))
return h('div', { class: 'text-center' }, t('globals.terms.name'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
@@ -15,7 +15,7 @@ export const createColumns = (t) => [
{
accessorKey: 'created_at',
header: function () {
return h('div', { class: 'text-center' }, t('form.field.createdAt'))
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'))
@@ -24,7 +24,7 @@ export const createColumns = (t) => [
{
accessorKey: 'updated_at',
header: function () {
return h('div', { class: 'text-center' }, t('form.field.updatedAt'))
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'))

View File

@@ -8,10 +8,10 @@
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="edit(props.role.id)">
{{ t('globals.buttons.edit') }}
{{ t('globals.messages.edit') }}
</DropdownMenuItem>
<DropdownMenuItem @click="() => (alertOpen = true)">
{{ t('globals.buttons.delete') }}
{{ t('globals.messages.delete') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -23,15 +23,19 @@
{{ t('globals.messages.areYouAbsolutelySure') }}
</AlertDialogTitle>
<AlertDialogDescription>
{{ t('admin.businessHours.deleteConfirmation') }}
{{
t('globals.messages.deletionConfirmation', {
name: t('globals.terms.businessHour').toLowerCase()
})
}}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{{ t('globals.buttons.cancel') }}
{{ t('globals.messages.cancel') }}
</AlertDialogCancel>
<AlertDialogAction @click="handleDelete">
{{ t('globals.buttons.delete') }}
{{ t('globals.messages.delete') }}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -46,7 +50,7 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
} from '@shared-ui/components/ui/dropdown-menu'
import {
AlertDialog,
AlertDialogAction,
@@ -56,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')),
@@ -17,7 +17,7 @@ export const createFormSchema = (t) => z.object({
if (!data.hours || Object.keys(data.hours).length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t('admin.business_hours.hours.required'),
message: t('globals.messages.required'),
path: ['hours']
})
} else {
@@ -25,7 +25,7 @@ export const createFormSchema = (t) => z.object({
if (!data.hours[day].open || !data.hours[day].close) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t('admin.business_hours.open_close.required'),
message: t('globals.messages.required'),
path: ['hours', day]
})
}

View File

@@ -2,7 +2,7 @@
<form class="space-y-6 w-full">
<FormField v-slot="{ componentField }" name="applies_to">
<FormItem>
<FormLabel>{{ $t('form.field.appliesTo') }}</FormLabel>
<FormLabel>{{ $t('globals.terms.appliesTo') }}</FormLabel>
<FormControl>
<Select v-bind="componentField" :modelValue="componentField.modelValue">
<SelectTrigger>
@@ -27,7 +27,7 @@
<FormField v-slot="{ componentField }" name="name">
<FormItem>
<FormLabel>{{ $t('form.field.name') }}</FormLabel>
<FormLabel>{{ $t('globals.terms.name') }}</FormLabel>
<FormControl>
<Input type="text" placeholder="" v-bind="componentField" />
</FormControl>
@@ -38,7 +38,7 @@
<FormField v-slot="{ componentField }" name="key">
<FormItem>
<FormLabel>{{ $t('form.field.key') }}</FormLabel>
<FormLabel>{{ $t('globals.terms.key') }}</FormLabel>
<FormControl>
<Input
type="text"
@@ -53,7 +53,7 @@
<FormField v-slot="{ componentField }" name="description">
<FormItem>
<FormLabel>{{ $t('form.field.description') }}</FormLabel>
<FormLabel>{{ $t('globals.terms.description') }}</FormLabel>
<FormControl>
<Input type="text" v-bind="componentField" />
</FormControl>
@@ -64,9 +64,9 @@
<FormField v-slot="{ componentField }" name="data_type">
<FormItem>
<FormLabel>{{ $t('form.field.type') }}</FormLabel>
<FormLabel>{{ $t('globals.terms.type') }}</FormLabel>
<FormControl>
<Select v-bind="componentField" :disabled="form.values.id && form.values.id > 0">
<Select v-bind="componentField" :disabled="!!(form.values.id && form.values.id > 0)">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
@@ -90,7 +90,7 @@
<FormField name="values" v-slot="{ componentField, handleChange }">
<FormItem v-show="form.values.data_type === 'list'">
<FormLabel>
{{ $t('form.field.listValues') }}
{{ $t('globals.terms.listValues') }}
</FormLabel>
<FormControl>
<TagsInput :modelValue="componentField.modelValue" @update:modelValue="handleChange">
@@ -108,7 +108,9 @@
<FormField name="regex" v-slot="{ componentField }">
<FormItem v-show="form.values.data_type === 'text'">
<FormLabel> {{ $t('form.field.regex') }} ({{ $t('form.field.optional') }}) </FormLabel>
<FormLabel>
{{ $t('globals.terms.regex') }} ({{ $t('globals.terms.optional') }})
</FormLabel>
<FormControl>
<Input type="text" v-bind="componentField" />
</FormControl>
@@ -121,7 +123,9 @@
<FormField name="regex_hint" v-slot="{ componentField }">
<FormItem v-show="form.values.data_type === 'text'">
<FormLabel> {{ $t('form.field.regexHint') }} ({{ $t('form.field.optional') }}) </FormLabel>
<FormLabel>
{{ $t('globals.terms.regexHint') }} ({{ $t('globals.terms.optional') }})
</FormLabel>
<FormControl>
<Input type="text" v-bind="componentField" />
</FormControl>
@@ -146,14 +150,14 @@ import {
FormItem,
FormLabel,
FormMessage
} from '@/components/ui/form'
} from '@shared-ui/components/ui/form'
import {
TagsInput,
TagsInputInput,
TagsInputItem,
TagsInputItemDelete,
TagsInputItemText
} from '@/components/ui/tags-input'
} from '@shared-ui/components/ui/tags-input'
import {
Select,
SelectContent,
@@ -161,8 +165,8 @@ import {
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { Input } from '@/components/ui/input'
} from '@shared-ui/components/ui/select'
import { Input } from '@shared-ui/components/ui/input'
const props = defineProps({
form: {

View File

@@ -6,7 +6,7 @@ export const createColumns = (t) => [
{
accessorKey: 'name',
header: function () {
return h('div', { class: 'text-center' }, t('form.field.name'))
return h('div', { class: 'text-center' }, t('globals.terms.name'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
@@ -15,7 +15,7 @@ export const createColumns = (t) => [
{
accessorKey: 'key',
header: function () {
return h('div', { class: 'text-center' }, t('form.field.key'))
return h('div', { class: 'text-center' }, t('globals.terms.key'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('key'))
@@ -24,7 +24,7 @@ export const createColumns = (t) => [
{
accessorKey: 'data_type',
header: function () {
return h('div', { class: 'text-center' }, t('form.field.type'))
return h('div', { class: 'text-center' }, t('globals.terms.type'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('data_type'))
@@ -33,7 +33,7 @@ export const createColumns = (t) => [
{
accessorKey: 'applies_to',
header: function () {
return h('div', { class: 'text-center' }, t('form.field.appliesTo'))
return h('div', { class: 'text-center' }, t('globals.terms.appliesTo'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('applies_to'))
@@ -42,7 +42,7 @@ export const createColumns = (t) => [
{
accessorKey: 'created_at',
header: function () {
return h('div', { class: 'text-center' }, t('form.field.createdAt'))
return h('div', { class: 'text-center' }, t('globals.terms.createdAt'))
},
cell: function ({ row }) {
return h(
@@ -55,7 +55,7 @@ export const createColumns = (t) => [
{
accessorKey: 'updated_at',
header: function () {
return h('div', { class: 'text-center' }, t('form.field.updatedAt'))
return h('div', { class: 'text-center' }, t('globals.terms.updatedAt'))
},
cell: function ({ row }) {
return h(

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