Compare commits

..

120 Commits

Author SHA1 Message Date
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
226 changed files with 8479 additions and 4018 deletions

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

@@ -38,7 +38,7 @@ frontend-build: install-deps
.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.
.PHONY: run-frontend
@@ -52,8 +52,8 @@ run-frontend:
.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 shares 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/)

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

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)
@@ -430,9 +484,6 @@ func handleUpdateConversationStatus(r *fastglue.Request) error {
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 +503,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)
@@ -534,33 +586,11 @@ func handleUpdateContactCustomAttributes(r *fastglue.Request) error {
if err := app.user.UpdateCustomAttributes(conversation.ContactID, attributes); 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 +622,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 +643,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 +662,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 +697,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 +707,11 @@ 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),
SourceChannelID: null.StringFrom(req.Email),
FirstName: req.FirstName,
LastName: req.LastName,
InboxID: req.InboxID,
}
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))
@@ -695,10 +721,10 @@ func handleCreateConversation(r *fastglue.Request) error {
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 +732,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 /**sender_id**/, conversationUUID, req.Content, to, nil /**cc**/, nil /**bcc**/, map[string]any{} /**meta**/); err != nil {
// Delete the conversation if reply fails.
if err := app.conversation.DeleteConversation(conversationUUID); err != nil {
app.lo.Error("error deleting conversation", "error", err)
@@ -716,14 +753,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

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

@@ -15,7 +15,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 +37,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 +110,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 +159,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"))

View File

@@ -47,11 +47,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 +60,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 +89,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 +98,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 +118,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 +127,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

View File

@@ -35,6 +35,7 @@ import (
notifier "github.com/abhinavxd/libredesk/internal/notification"
emailnotifier "github.com/abhinavxd/libredesk/internal/notification/providers/email"
"github.com/abhinavxd/libredesk/internal/oidc"
"github.com/abhinavxd/libredesk/internal/report"
"github.com/abhinavxd/libredesk/internal/role"
"github.com/abhinavxd/libredesk/internal/search"
"github.com/abhinavxd/libredesk/internal/setting"
@@ -44,6 +45,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"
@@ -219,8 +221,9 @@ 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{
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"),
@@ -823,6 +826,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")

View File

@@ -9,17 +9,30 @@ import (
"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)
}

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"
@@ -40,6 +41,7 @@ import (
"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"
@@ -90,6 +92,8 @@ type App struct {
activityLog *activitylog.Manager
notifier *notifier.Service
customAttribute *customAttribute.Manager
report *report.Manager
webhook *webhook.Manager
// Global state that stores data on an available app update.
update *AppUpdate
@@ -157,13 +161,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,12 +193,13 @@ 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)
)
automation.SetConversationStore(conversation)
@@ -194,6 +209,7 @@ func main() {
go autoassigner.Run(ctx, autoAssignInterval)
go conversation.Run(ctx, messageIncomingQWorkers, messageOutgoingQWorkers, messageOutgoingScanInterval)
go conversation.RunUnsnoozer(ctx, unsnoozeInterval)
go webhook.Run(ctx)
go notifier.Run(ctx)
go sla.Run(ctx, slaEvaluationInterval)
go sla.SendNotifications(ctx)
@@ -224,12 +240,14 @@ 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,
}
app.consts.Store(constants)
@@ -273,6 +291,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

@@ -4,7 +4,6 @@ import (
"strconv"
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
"github.com/abhinavxd/libredesk/internal/automation/models"
"github.com/abhinavxd/libredesk/internal/envelope"
medModels "github.com/abhinavxd/libredesk/internal/media/models"
"github.com/valyala/fasthttp"
@@ -132,7 +131,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{}
)
@@ -153,6 +151,7 @@ func handleSendMessage(r *fastglue.Request) error {
}
// 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 {
@@ -170,8 +169,6 @@ func handleSendMessage(r *fastglue.Request) error {
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(true)
}

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,25 @@ 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)
// 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 +121,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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -34,6 +34,7 @@ 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},
}
// 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,29 +90,35 @@ func handleGetAgent(r *fastglue.Request) error {
// handleUpdateAgentAvailability updates the current agent availability.
func handleUpdateAgentAvailability(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
status = string(r.RequestCtx.PostArgs().Peek("status"))
ip = realip.FromRequest(r.RequestCtx)
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
ip = realip.FromRequest(r.RequestCtx)
availReq AvailabilityRequest
)
// Decode JSON request
if err := r.Decode(&availReq, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
agent, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
// Same status?
if agent.AvailabilityStatus == status {
if agent.AvailabilityStatus == availReq.Status {
return r.SendEnvelope(true)
}
// Update availability status.
if err := app.user.UpdateAvailability(auser.ID, status); err != nil {
if err := app.user.UpdateAvailability(auser.ID, availReq.Status); err != nil {
return sendErrorEnvelope(r, err)
}
// Skip activity log if agent returns online from away (to avoid spam).
if !(agent.AvailabilityStatus == models.Away && status == models.Online) {
if err := app.activityLog.UserAvailability(auser.ID, auser.Email, status, ip, "", 0); err != nil {
if !(agent.AvailabilityStatus == models.Away && availReq.Status == models.Online) {
if err := app.activityLog.UserAvailability(auser.ID, auser.Email, availReq.Status, ip, "", 0); err != nil {
app.lo.Error("error creating activity log", "error", err)
}
}
@@ -156,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)
@@ -165,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)
}
@@ -214,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)
}
@@ -227,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)
@@ -247,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 {
@@ -339,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.")
@@ -389,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)
}
@@ -472,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
}

View File

@@ -1,80 +1,124 @@
# 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"
[sla]
# How often to evaluate SLA compliance for conversations
evaluation_interval = "5m"

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 are delivered with a 10-second timeout
- 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,10 @@ 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
- Contributions:
- Developer Setup: developer-setup.md
- Translate Libredesk: translations.md

View File

@@ -38,7 +38,7 @@ describe('Login Component', () => {
it('should show error for invalid login attempt', () => {
// Mock failed login API call
cy.intercept('POST', '**/api/v1/login', {
cy.intercept('POST', '**/api/v1/auth/login', {
statusCode: 401,
body: {
message: 'Invalid credentials'
@@ -61,7 +61,7 @@ describe('Login Component', () => {
it('should login successfully with correct credentials', () => {
// Mock successful login API call
cy.intercept('POST', '**/api/v1/login', {
cy.intercept('POST', '**/api/v1/auth/login', {
statusCode: 200,
body: {
data: {
@@ -111,7 +111,7 @@ describe('Login Component', () => {
it('should show loading state during login', () => {
// Mock slow API response
cy.intercept('POST', '**/api/v1/login', {
cy.intercept('POST', '**/api/v1/auth/login', {
statusCode: 200,
body: {
data: {
@@ -132,7 +132,7 @@ describe('Login Component', () => {
// Check if loading state is shown
cy.contains('Logging in...').should('be.visible')
cy.get('svg.animate-spin').should('be.visible')
cy.get('.animate-spin').should('be.visible')
// Wait for API call to finish
cy.wait('@slowLogin')

View File

@@ -7,6 +7,8 @@
"dev": "pnpm exec vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest",
"test:run": "vitest run",
"test:e2e": "start-server-and-test preview http://localhost:4173 'cypress run --e2e'",
"test:e2e:ci": "cypress run --e2e --headless",
"test:e2e:dev": "start-server-and-test 'vite dev --port 4173' http://localhost:4173 'cypress open --e2e'",
@@ -74,7 +76,8 @@
"start-server-and-test": "^2.0.3",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"vite": "^5.4.18"
"vite": "^5.4.19",
"vitest": "^3.2.2"
},
"packageManager": "pnpm@9.15.3+sha512.1f79bc245a66eb0b07c5d4d83131240774642caaa86ef7d0434ab47c0d16f66b04e21e0c086eb61e62c77efc4d7f7ec071afad3796af64892fae66509173893a"
}
}

523
frontend/pnpm-lock.yaml generated
View File

@@ -140,7 +140,7 @@ importers:
version: 1.10.5
'@vitejs/plugin-vue':
specifier: ^5.0.3
version: 5.2.1(vite@5.4.18(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0))(vue@3.5.13(typescript@5.7.3))
version: 5.2.1(vite@5.4.19(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0))(vue@3.5.13(typescript@5.7.3))
'@vue/eslint-config-prettier':
specifier: ^8.0.0
version: 8.0.0(eslint@8.57.1)(prettier@3.4.2)
@@ -178,8 +178,11 @@ importers:
specifier: ^1.0.7
version: 1.0.7(tailwindcss@3.4.17)
vite:
specifier: ^5.4.18
version: 5.4.18(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)
specifier: ^5.4.19
version: 5.4.19(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)
vitest:
specifier: ^3.2.2
version: 3.2.2(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)
packages:
@@ -645,103 +648,103 @@ packages:
'@remirror/core-constants@3.0.0':
resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==}
'@rollup/rollup-android-arm-eabi@4.40.1':
resolution: {integrity: sha512-kxz0YeeCrRUHz3zyqvd7n+TVRlNyTifBsmnmNPtk3hQURUyG9eAB+usz6DAwagMusjx/zb3AjvDUvhFGDAexGw==}
'@rollup/rollup-android-arm-eabi@4.41.1':
resolution: {integrity: sha512-NELNvyEWZ6R9QMkiytB4/L4zSEaBC03KIXEghptLGLZWJ6VPrL63ooZQCOnlx36aQPGhzuOMwDerC1Eb2VmrLw==}
cpu: [arm]
os: [android]
'@rollup/rollup-android-arm64@4.40.1':
resolution: {integrity: sha512-PPkxTOisoNC6TpnDKatjKkjRMsdaWIhyuMkA4UsBXT9WEZY4uHezBTjs6Vl4PbqQQeu6oION1w2voYZv9yquCw==}
'@rollup/rollup-android-arm64@4.41.1':
resolution: {integrity: sha512-DXdQe1BJ6TK47ukAoZLehRHhfKnKg9BjnQYUu9gzhI8Mwa1d2fzxA1aw2JixHVl403bwp1+/o/NhhHtxWJBgEA==}
cpu: [arm64]
os: [android]
'@rollup/rollup-darwin-arm64@4.40.1':
resolution: {integrity: sha512-VWXGISWFY18v/0JyNUy4A46KCFCb9NVsH+1100XP31lud+TzlezBbz24CYzbnA4x6w4hx+NYCXDfnvDVO6lcAA==}
'@rollup/rollup-darwin-arm64@4.41.1':
resolution: {integrity: sha512-5afxvwszzdulsU2w8JKWwY8/sJOLPzf0e1bFuvcW5h9zsEg+RQAojdW0ux2zyYAz7R8HvvzKCjLNJhVq965U7w==}
cpu: [arm64]
os: [darwin]
'@rollup/rollup-darwin-x64@4.40.1':
resolution: {integrity: sha512-nIwkXafAI1/QCS7pxSpv/ZtFW6TXcNUEHAIA9EIyw5OzxJZQ1YDrX+CL6JAIQgZ33CInl1R6mHet9Y/UZTg2Bw==}
'@rollup/rollup-darwin-x64@4.41.1':
resolution: {integrity: sha512-egpJACny8QOdHNNMZKf8xY0Is6gIMz+tuqXlusxquWu3F833DcMwmGM7WlvCO9sB3OsPjdC4U0wHw5FabzCGZg==}
cpu: [x64]
os: [darwin]
'@rollup/rollup-freebsd-arm64@4.40.1':
resolution: {integrity: sha512-BdrLJ2mHTrIYdaS2I99mriyJfGGenSaP+UwGi1kB9BLOCu9SR8ZpbkmmalKIALnRw24kM7qCN0IOm6L0S44iWw==}
'@rollup/rollup-freebsd-arm64@4.41.1':
resolution: {integrity: sha512-DBVMZH5vbjgRk3r0OzgjS38z+atlupJ7xfKIDJdZZL6sM6wjfDNo64aowcLPKIx7LMQi8vybB56uh1Ftck/Atg==}
cpu: [arm64]
os: [freebsd]
'@rollup/rollup-freebsd-x64@4.40.1':
resolution: {integrity: sha512-VXeo/puqvCG8JBPNZXZf5Dqq7BzElNJzHRRw3vjBE27WujdzuOPecDPc/+1DcdcTptNBep3861jNq0mYkT8Z6Q==}
'@rollup/rollup-freebsd-x64@4.41.1':
resolution: {integrity: sha512-3FkydeohozEskBxNWEIbPfOE0aqQgB6ttTkJ159uWOFn42VLyfAiyD9UK5mhu+ItWzft60DycIN1Xdgiy8o/SA==}
cpu: [x64]
os: [freebsd]
'@rollup/rollup-linux-arm-gnueabihf@4.40.1':
resolution: {integrity: sha512-ehSKrewwsESPt1TgSE/na9nIhWCosfGSFqv7vwEtjyAqZcvbGIg4JAcV7ZEh2tfj/IlfBeZjgOXm35iOOjadcg==}
'@rollup/rollup-linux-arm-gnueabihf@4.41.1':
resolution: {integrity: sha512-wC53ZNDgt0pqx5xCAgNunkTzFE8GTgdZ9EwYGVcg+jEjJdZGtq9xPjDnFgfFozQI/Xm1mh+D9YlYtl+ueswNEg==}
cpu: [arm]
os: [linux]
'@rollup/rollup-linux-arm-musleabihf@4.40.1':
resolution: {integrity: sha512-m39iO/aaurh5FVIu/F4/Zsl8xppd76S4qoID8E+dSRQvTyZTOI2gVk3T4oqzfq1PtcvOfAVlwLMK3KRQMaR8lg==}
'@rollup/rollup-linux-arm-musleabihf@4.41.1':
resolution: {integrity: sha512-jwKCca1gbZkZLhLRtsrka5N8sFAaxrGz/7wRJ8Wwvq3jug7toO21vWlViihG85ei7uJTpzbXZRcORotE+xyrLA==}
cpu: [arm]
os: [linux]
'@rollup/rollup-linux-arm64-gnu@4.40.1':
resolution: {integrity: sha512-Y+GHnGaku4aVLSgrT0uWe2o2Rq8te9hi+MwqGF9r9ORgXhmHK5Q71N757u0F8yU1OIwUIFy6YiJtKjtyktk5hg==}
'@rollup/rollup-linux-arm64-gnu@4.41.1':
resolution: {integrity: sha512-g0UBcNknsmmNQ8V2d/zD2P7WWfJKU0F1nu0k5pW4rvdb+BIqMm8ToluW/eeRmxCared5dD76lS04uL4UaNgpNA==}
cpu: [arm64]
os: [linux]
'@rollup/rollup-linux-arm64-musl@4.40.1':
resolution: {integrity: sha512-jEwjn3jCA+tQGswK3aEWcD09/7M5wGwc6+flhva7dsQNRZZTe30vkalgIzV4tjkopsTS9Jd7Y1Bsj6a4lzz8gQ==}
'@rollup/rollup-linux-arm64-musl@4.41.1':
resolution: {integrity: sha512-XZpeGB5TKEZWzIrj7sXr+BEaSgo/ma/kCgrZgL0oo5qdB1JlTzIYQKel/RmhT6vMAvOdM2teYlAaOGJpJ9lahg==}
cpu: [arm64]
os: [linux]
'@rollup/rollup-linux-loongarch64-gnu@4.40.1':
resolution: {integrity: sha512-ySyWikVhNzv+BV/IDCsrraOAZ3UaC8SZB67FZlqVwXwnFhPihOso9rPOxzZbjp81suB1O2Topw+6Ug3JNegejQ==}
'@rollup/rollup-linux-loongarch64-gnu@4.41.1':
resolution: {integrity: sha512-bkCfDJ4qzWfFRCNt5RVV4DOw6KEgFTUZi2r2RuYhGWC8WhCA8lCAJhDeAmrM/fdiAH54m0mA0Vk2FGRPyzI+tw==}
cpu: [loong64]
os: [linux]
'@rollup/rollup-linux-powerpc64le-gnu@4.40.1':
resolution: {integrity: sha512-BvvA64QxZlh7WZWqDPPdt0GH4bznuL6uOO1pmgPnnv86rpUpc8ZxgZwcEgXvo02GRIZX1hQ0j0pAnhwkhwPqWg==}
'@rollup/rollup-linux-powerpc64le-gnu@4.41.1':
resolution: {integrity: sha512-3mr3Xm+gvMX+/8EKogIZSIEF0WUu0HL9di+YWlJpO8CQBnoLAEL/roTCxuLncEdgcfJcvA4UMOf+2dnjl4Ut1A==}
cpu: [ppc64]
os: [linux]
'@rollup/rollup-linux-riscv64-gnu@4.40.1':
resolution: {integrity: sha512-EQSP+8+1VuSulm9RKSMKitTav89fKbHymTf25n5+Yr6gAPZxYWpj3DzAsQqoaHAk9YX2lwEyAf9S4W8F4l3VBQ==}
'@rollup/rollup-linux-riscv64-gnu@4.41.1':
resolution: {integrity: sha512-3rwCIh6MQ1LGrvKJitQjZFuQnT2wxfU+ivhNBzmxXTXPllewOF7JR1s2vMX/tWtUYFgphygxjqMl76q4aMotGw==}
cpu: [riscv64]
os: [linux]
'@rollup/rollup-linux-riscv64-musl@4.40.1':
resolution: {integrity: sha512-n/vQ4xRZXKuIpqukkMXZt9RWdl+2zgGNx7Uda8NtmLJ06NL8jiHxUawbwC+hdSq1rrw/9CghCpEONor+l1e2gA==}
'@rollup/rollup-linux-riscv64-musl@4.41.1':
resolution: {integrity: sha512-LdIUOb3gvfmpkgFZuccNa2uYiqtgZAz3PTzjuM5bH3nvuy9ty6RGc/Q0+HDFrHrizJGVpjnTZ1yS5TNNjFlklw==}
cpu: [riscv64]
os: [linux]
'@rollup/rollup-linux-s390x-gnu@4.40.1':
resolution: {integrity: sha512-h8d28xzYb98fMQKUz0w2fMc1XuGzLLjdyxVIbhbil4ELfk5/orZlSTpF/xdI9C8K0I8lCkq+1En2RJsawZekkg==}
'@rollup/rollup-linux-s390x-gnu@4.41.1':
resolution: {integrity: sha512-oIE6M8WC9ma6xYqjvPhzZYk6NbobIURvP/lEbh7FWplcMO6gn7MM2yHKA1eC/GvYwzNKK/1LYgqzdkZ8YFxR8g==}
cpu: [s390x]
os: [linux]
'@rollup/rollup-linux-x64-gnu@4.40.1':
resolution: {integrity: sha512-XiK5z70PEFEFqcNj3/zRSz/qX4bp4QIraTy9QjwJAb/Z8GM7kVUsD0Uk8maIPeTyPCP03ChdI+VVmJriKYbRHQ==}
'@rollup/rollup-linux-x64-gnu@4.41.1':
resolution: {integrity: sha512-cWBOvayNvA+SyeQMp79BHPK8ws6sHSsYnK5zDcsC3Hsxr1dgTABKjMnMslPq1DvZIp6uO7kIWhiGwaTdR4Og9A==}
cpu: [x64]
os: [linux]
'@rollup/rollup-linux-x64-musl@4.40.1':
resolution: {integrity: sha512-2BRORitq5rQ4Da9blVovzNCMaUlyKrzMSvkVR0D4qPuOy/+pMCrh1d7o01RATwVy+6Fa1WBw+da7QPeLWU/1mQ==}
'@rollup/rollup-linux-x64-musl@4.41.1':
resolution: {integrity: sha512-y5CbN44M+pUCdGDlZFzGGBSKCA4A/J2ZH4edTYSSxFg7ce1Xt3GtydbVKWLlzL+INfFIZAEg1ZV6hh9+QQf9YQ==}
cpu: [x64]
os: [linux]
'@rollup/rollup-win32-arm64-msvc@4.40.1':
resolution: {integrity: sha512-b2bcNm9Kbde03H+q+Jjw9tSfhYkzrDUf2d5MAd1bOJuVplXvFhWz7tRtWvD8/ORZi7qSCy0idW6tf2HgxSXQSg==}
'@rollup/rollup-win32-arm64-msvc@4.41.1':
resolution: {integrity: sha512-lZkCxIrjlJlMt1dLO/FbpZbzt6J/A8p4DnqzSa4PWqPEUUUnzXLeki/iyPLfV0BmHItlYgHUqJe+3KiyydmiNQ==}
cpu: [arm64]
os: [win32]
'@rollup/rollup-win32-ia32-msvc@4.40.1':
resolution: {integrity: sha512-DfcogW8N7Zg7llVEfpqWMZcaErKfsj9VvmfSyRjCyo4BI3wPEfrzTtJkZG6gKP/Z92wFm6rz2aDO7/JfiR/whA==}
'@rollup/rollup-win32-ia32-msvc@4.41.1':
resolution: {integrity: sha512-+psFT9+pIh2iuGsxFYYa/LhS5MFKmuivRsx9iPJWNSGbh2XVEjk90fmpUEjCnILPEPJnikAU6SFDiEUyOv90Pg==}
cpu: [ia32]
os: [win32]
'@rollup/rollup-win32-x64-msvc@4.40.1':
resolution: {integrity: sha512-ECyOuDeH3C1I8jH2MK1RtBJW+YPMvSfT0a5NN0nHfQYnDSJ6tUiZH3gzwVP5/Kfh/+Tt7tpWVF9LXNTnhTJ3kA==}
'@rollup/rollup-win32-x64-msvc@4.41.1':
resolution: {integrity: sha512-Wq2zpapRYLfi4aKxf2Xff0tN+7slj2d4R87WEzqw7ZLsVvO5zwYCIuEGSZYiK41+GlwUo1HiR+GdkLEJnCKTCw==}
cpu: [x64]
os: [win32]
@@ -959,6 +962,9 @@ packages:
'@tiptap/pm': ^2.7.0
vue: ^3.0.0
'@types/chai@5.2.2':
resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==}
'@types/d3-array@3.2.1':
resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==}
@@ -1067,6 +1073,9 @@ packages:
'@types/dagre@0.7.52':
resolution: {integrity: sha512-XKJdy+OClLk3hketHi9Qg6gTfe1F3y+UFnHxKA2rn9Dw+oXa4Gb378Ztz9HlMgZKSxpPmn4BNVh9wgkpvrK1uw==}
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/estree@1.0.7':
resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==}
@@ -1175,6 +1184,35 @@ packages:
vite: ^5.0.0 || ^6.0.0
vue: ^3.2.25
'@vitest/expect@3.2.2':
resolution: {integrity: sha512-ipHw0z669vEMjzz3xQE8nJX1s0rQIb7oEl4jjl35qWTwm/KIHERIg/p/zORrjAaZKXfsv7IybcNGHwhOOAPMwQ==}
'@vitest/mocker@3.2.2':
resolution: {integrity: sha512-jKojcaRyIYpDEf+s7/dD3LJt53c0dPfp5zCPXz9H/kcGrSlovU/t1yEaNzM9oFME3dcd4ULwRI/x0Po1Zf+LTw==}
peerDependencies:
msw: ^2.4.9
vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0
peerDependenciesMeta:
msw:
optional: true
vite:
optional: true
'@vitest/pretty-format@3.2.2':
resolution: {integrity: sha512-FY4o4U1UDhO9KMd2Wee5vumwcaHw7Vg4V7yR4Oq6uK34nhEJOmdRYrk3ClburPRUA09lXD/oXWZ8y/Sdma0aUQ==}
'@vitest/runner@3.2.2':
resolution: {integrity: sha512-GYcHcaS3ejGRZYed2GAkvsjBeXIEerDKdX3orQrBJqLRiea4NSS9qvn9Nxmuy1IwIB+EjFOaxXnX79l8HFaBwg==}
'@vitest/snapshot@3.2.2':
resolution: {integrity: sha512-aMEI2XFlR1aNECbBs5C5IZopfi5Lb8QJZGGpzS8ZUHML5La5wCbrbhLOVSME68qwpT05ROEEOAZPRXFpxZV2wA==}
'@vitest/spy@3.2.2':
resolution: {integrity: sha512-6Utxlx3o7pcTxvp0u8kUiXtRFScMrUg28KjB3R2hon7w4YqOFAEA9QwzPVVS1QNL3smo4xRNOpNZClRVfpMcYg==}
'@vitest/utils@3.2.2':
resolution: {integrity: sha512-qJYMllrWpF/OYfWHP32T31QCaLa3BAzT/n/8mNGhPdVcjY+JYazQFO1nsJvXU12Kp1xMpNY4AGuljPTNjQve6A==}
'@vue/compiler-core@3.5.13':
resolution: {integrity: sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q==}
@@ -1320,6 +1358,10 @@ packages:
resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==}
engines: {node: '>=0.8'}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
astral-regex@2.0.0:
resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==}
engines: {node: '>=8'}
@@ -1405,6 +1447,10 @@ packages:
buffer@5.7.1:
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
cac@6.7.14:
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
engines: {node: '>=8'}
cachedir@2.4.0:
resolution: {integrity: sha512-9EtFOZR8g22CL7BWjJ9BUx1+A/djkofnyW3aOXZORNW2kxoUpx2h+uN2cOqwPmFhnpVmxg+KW2OjOSgChTEvsQ==}
engines: {node: '>=6'}
@@ -1435,10 +1481,18 @@ packages:
caseless@0.12.0:
resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==}
chai@5.2.0:
resolution: {integrity: sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==}
engines: {node: '>=12'}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
check-error@2.1.1:
resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==}
engines: {node: '>= 16'}
check-more-types@2.24.0:
resolution: {integrity: sha512-Pj779qHxV2tuapviy1bSZNEL1maXr13bPYpsvSDB68HlYcYuhlDrmGd63i0JHMCLKzc7rUSNIrpdJlhVlNwrxA==}
engines: {node: '>= 0.8.0'}
@@ -1737,10 +1791,23 @@ packages:
supports-color:
optional: true
debug@4.4.1:
resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==}
engines: {node: '>=6.0'}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
decode-uri-component@0.2.2:
resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==}
engines: {node: '>=0.10'}
deep-eql@5.0.2:
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
engines: {node: '>=6'}
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
@@ -1822,6 +1889,9 @@ packages:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'}
es-module-lexer@1.7.0:
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
es-object-atoms@1.0.0:
resolution: {integrity: sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==}
engines: {node: '>= 0.4'}
@@ -1915,6 +1985,9 @@ packages:
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
esutils@2.0.3:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
@@ -1937,6 +2010,10 @@ packages:
resolution: {integrity: sha512-8iA79xD3uAch729dUG8xaaBBFGaEa0wdD2VkYLFHwlqosEj/jT66AzcreRDSgV7ehnNLBW2WR5jIXwGKjVdTLg==}
engines: {node: '>=4'}
expect-type@1.2.1:
resolution: {integrity: sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==}
engines: {node: '>=12.0.0'}
extend@3.0.2:
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
@@ -1971,6 +2048,14 @@ packages:
fd-slicer@1.1.0:
resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==}
fdir@6.4.5:
resolution: {integrity: sha512-4BG7puHpVsIYxZUbiUE3RqGloLaSSwzYie5jvasC4LWuBWzZawynvYouhjbQKw2JuIGYdm0DzIxl8iVidKlUEw==}
peerDependencies:
picomatch: ^3 || ^4
peerDependenciesMeta:
picomatch:
optional: true
figures@3.2.0:
resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==}
engines: {node: '>=8'}
@@ -2379,6 +2464,9 @@ packages:
resolution: {integrity: sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==}
engines: {node: '>=10'}
loupe@3.1.3:
resolution: {integrity: sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==}
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
@@ -2576,6 +2664,13 @@ packages:
resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
engines: {node: '>=8'}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
pathval@2.0.0:
resolution: {integrity: sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==}
engines: {node: '>= 14.16'}
pause-stream@0.0.11:
resolution: {integrity: sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==}
@@ -2599,6 +2694,10 @@ packages:
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
engines: {node: '>=8.6'}
picomatch@4.0.2:
resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==}
engines: {node: '>=12'}
pify@2.3.0:
resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
engines: {node: '>=0.10.0'}
@@ -2840,8 +2939,8 @@ packages:
robust-predicates@3.0.2:
resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==}
rollup@4.40.1:
resolution: {integrity: sha512-C5VvvgCCyfyotVITIAv+4efVytl5F7wt+/I2i9q9GZcEXW9BP52YYOXC58igUi+LFZVHukErIIqQSWwv/M3WRw==}
rollup@4.41.1:
resolution: {integrity: sha512-cPmwD3FnFv8rKMBc1MxWCwVQFxwf1JEmSX3iQXrRVVG15zerAIXRjMFVWnd5Q5QvgKF7Aj+5ykXFhUl+QGnyOw==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
@@ -2900,6 +2999,9 @@ packages:
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
engines: {node: '>= 0.4'}
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
signal-exit@3.0.7:
resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
@@ -2950,11 +3052,17 @@ packages:
engines: {node: '>=0.10.0'}
hasBin: true
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
start-server-and-test@2.0.9:
resolution: {integrity: sha512-DDceIvc4wdpr+z3Aqkot2QMho8TcUBh5qH0wEHDpEexBTzlheOcmh53d3dExABY4J5C7qS2UbSXqRWLtxpbWIQ==}
engines: {node: '>=16'}
hasBin: true
std-env@3.9.0:
resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==}
stream-combiner@0.0.4:
resolution: {integrity: sha512-rT00SPnTVyRsaSz5zgSPma/aHSOic5U1prhYdRy5HS2kTZviFpmDgzilbtsJsxiroqACmayynDN/9VzIbX5DOw==}
@@ -3056,9 +3164,31 @@ packages:
through@2.3.8:
resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
tinyexec@0.3.2:
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
tinyglobby@0.2.14:
resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==}
engines: {node: '>=12.0.0'}
tinypool@1.1.0:
resolution: {integrity: sha512-7CotroY9a8DKsKprEy/a14aCCm8jYVmR7aFy4fpkZM8sdpNJbKkixuNjgM50yCmip2ezc8z4N7k3oe2+rfRJCQ==}
engines: {node: ^18.0.0 || >=20.0.0}
tinyqueue@2.0.3:
resolution: {integrity: sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==}
tinyrainbow@2.0.0:
resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==}
engines: {node: '>=14.0.0'}
tinyspy@4.0.3:
resolution: {integrity: sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A==}
engines: {node: '>=14.0.0'}
tippy.js@6.3.7:
resolution: {integrity: sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==}
@@ -3164,8 +3294,13 @@ packages:
resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==}
engines: {'0': node >=0.6.0}
vite@5.4.18:
resolution: {integrity: sha512-1oDcnEp3lVyHCuQ2YFelM4Alm2o91xNoMncRm1U7S+JdYfYOvbiGZ3/CxGttrOu2M/KcGz7cRC2DoNUA6urmMA==}
vite-node@3.2.2:
resolution: {integrity: sha512-Xj/jovjZvDXOq2FgLXu8NsY4uHUMWtzVmMC2LkCu9HWdr9Qu1Is5sanX3Z4jOFKdohfaWDnEJWp9pRP0vVpAcA==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
hasBin: true
vite@5.4.19:
resolution: {integrity: sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
peerDependencies:
@@ -3195,6 +3330,34 @@ packages:
terser:
optional: true
vitest@3.2.2:
resolution: {integrity: sha512-fyNn/Rp016Bt5qvY0OQvIUCwW2vnaEBLxP42PmKbNIoasSYjML+8xyeADOPvBe+Xfl/ubIw4og7Lt9jflRsCNw==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@types/debug': ^4.1.12
'@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
'@vitest/browser': 3.2.2
'@vitest/ui': 3.2.2
happy-dom: '*'
jsdom: '*'
peerDependenciesMeta:
'@edge-runtime/vm':
optional: true
'@types/debug':
optional: true
'@types/node':
optional: true
'@vitest/browser':
optional: true
'@vitest/ui':
optional: true
happy-dom:
optional: true
jsdom:
optional: true
vt-pbf@3.1.3:
resolution: {integrity: sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==}
@@ -3276,6 +3439,11 @@ packages:
engines: {node: '>= 8'}
hasBin: true
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
hasBin: true
word-wrap@1.2.5:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
@@ -3754,64 +3922,64 @@ snapshots:
'@remirror/core-constants@3.0.0': {}
'@rollup/rollup-android-arm-eabi@4.40.1':
'@rollup/rollup-android-arm-eabi@4.41.1':
optional: true
'@rollup/rollup-android-arm64@4.40.1':
'@rollup/rollup-android-arm64@4.41.1':
optional: true
'@rollup/rollup-darwin-arm64@4.40.1':
'@rollup/rollup-darwin-arm64@4.41.1':
optional: true
'@rollup/rollup-darwin-x64@4.40.1':
'@rollup/rollup-darwin-x64@4.41.1':
optional: true
'@rollup/rollup-freebsd-arm64@4.40.1':
'@rollup/rollup-freebsd-arm64@4.41.1':
optional: true
'@rollup/rollup-freebsd-x64@4.40.1':
'@rollup/rollup-freebsd-x64@4.41.1':
optional: true
'@rollup/rollup-linux-arm-gnueabihf@4.40.1':
'@rollup/rollup-linux-arm-gnueabihf@4.41.1':
optional: true
'@rollup/rollup-linux-arm-musleabihf@4.40.1':
'@rollup/rollup-linux-arm-musleabihf@4.41.1':
optional: true
'@rollup/rollup-linux-arm64-gnu@4.40.1':
'@rollup/rollup-linux-arm64-gnu@4.41.1':
optional: true
'@rollup/rollup-linux-arm64-musl@4.40.1':
'@rollup/rollup-linux-arm64-musl@4.41.1':
optional: true
'@rollup/rollup-linux-loongarch64-gnu@4.40.1':
'@rollup/rollup-linux-loongarch64-gnu@4.41.1':
optional: true
'@rollup/rollup-linux-powerpc64le-gnu@4.40.1':
'@rollup/rollup-linux-powerpc64le-gnu@4.41.1':
optional: true
'@rollup/rollup-linux-riscv64-gnu@4.40.1':
'@rollup/rollup-linux-riscv64-gnu@4.41.1':
optional: true
'@rollup/rollup-linux-riscv64-musl@4.40.1':
'@rollup/rollup-linux-riscv64-musl@4.41.1':
optional: true
'@rollup/rollup-linux-s390x-gnu@4.40.1':
'@rollup/rollup-linux-s390x-gnu@4.41.1':
optional: true
'@rollup/rollup-linux-x64-gnu@4.40.1':
'@rollup/rollup-linux-x64-gnu@4.41.1':
optional: true
'@rollup/rollup-linux-x64-musl@4.40.1':
'@rollup/rollup-linux-x64-musl@4.41.1':
optional: true
'@rollup/rollup-win32-arm64-msvc@4.40.1':
'@rollup/rollup-win32-arm64-msvc@4.41.1':
optional: true
'@rollup/rollup-win32-ia32-msvc@4.40.1':
'@rollup/rollup-win32-ia32-msvc@4.41.1':
optional: true
'@rollup/rollup-win32-x64-msvc@4.40.1':
'@rollup/rollup-win32-x64-msvc@4.41.1':
optional: true
'@rushstack/eslint-patch@1.10.5': {}
@@ -4039,6 +4207,10 @@ snapshots:
'@tiptap/pm': 2.11.2
vue: 3.5.13(typescript@5.7.3)
'@types/chai@5.2.2':
dependencies:
'@types/deep-eql': 4.0.2
'@types/d3-array@3.2.1': {}
'@types/d3-axis@3.0.6':
@@ -4170,6 +4342,8 @@ snapshots:
'@types/dagre@0.7.52': {}
'@types/deep-eql@4.0.2': {}
'@types/estree@1.0.7': {}
'@types/geojson@7946.0.15': {}
@@ -4318,11 +4492,52 @@ snapshots:
transitivePeerDependencies:
- vue
'@vitejs/plugin-vue@5.2.1(vite@5.4.18(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0))(vue@3.5.13(typescript@5.7.3))':
'@vitejs/plugin-vue@5.2.1(vite@5.4.19(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0))(vue@3.5.13(typescript@5.7.3))':
dependencies:
vite: 5.4.18(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)
vite: 5.4.19(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)
vue: 3.5.13(typescript@5.7.3)
'@vitest/expect@3.2.2':
dependencies:
'@types/chai': 5.2.2
'@vitest/spy': 3.2.2
'@vitest/utils': 3.2.2
chai: 5.2.0
tinyrainbow: 2.0.0
'@vitest/mocker@3.2.2(vite@5.4.19(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0))':
dependencies:
'@vitest/spy': 3.2.2
estree-walker: 3.0.3
magic-string: 0.30.17
optionalDependencies:
vite: 5.4.19(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)
'@vitest/pretty-format@3.2.2':
dependencies:
tinyrainbow: 2.0.0
'@vitest/runner@3.2.2':
dependencies:
'@vitest/utils': 3.2.2
pathe: 2.0.3
'@vitest/snapshot@3.2.2':
dependencies:
'@vitest/pretty-format': 3.2.2
magic-string: 0.30.17
pathe: 2.0.3
'@vitest/spy@3.2.2':
dependencies:
tinyspy: 4.0.3
'@vitest/utils@3.2.2':
dependencies:
'@vitest/pretty-format': 3.2.2
loupe: 3.1.3
tinyrainbow: 2.0.0
'@vue/compiler-core@3.5.13':
dependencies:
'@babel/parser': 7.26.5
@@ -4520,6 +4735,8 @@ snapshots:
assert-plus@1.0.0: {}
assertion-error@2.0.1: {}
astral-regex@2.0.0: {}
async@3.2.6: {}
@@ -4604,6 +4821,8 @@ snapshots:
base64-js: 1.5.1
ieee754: 1.2.1
cac@6.7.14: {}
cachedir@2.4.0: {}
call-bind-apply-helpers@1.0.1:
@@ -4629,11 +4848,21 @@ snapshots:
caseless@0.12.0: {}
chai@5.2.0:
dependencies:
assertion-error: 2.0.1
check-error: 2.1.1
deep-eql: 5.0.2
loupe: 3.1.3
pathval: 2.0.0
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
supports-color: 7.2.0
check-error@2.1.1: {}
check-more-types@2.24.0: {}
chokidar@3.6.0:
@@ -4988,9 +5217,15 @@ snapshots:
optionalDependencies:
supports-color: 8.1.1
debug@4.4.1:
dependencies:
ms: 2.1.3
decode-uri-component@0.2.2:
optional: true
deep-eql@5.0.2: {}
deep-is@0.1.4: {}
defu@6.1.4: {}
@@ -5060,6 +5295,8 @@ snapshots:
es-errors@1.3.0: {}
es-module-lexer@1.7.0: {}
es-object-atoms@1.0.0:
dependencies:
es-errors: 1.3.0
@@ -5207,6 +5444,10 @@ snapshots:
estree-walker@2.0.2: {}
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.7
esutils@2.0.3: {}
event-stream@3.3.4:
@@ -5249,6 +5490,8 @@ snapshots:
dependencies:
pify: 2.3.0
expect-type@1.2.1: {}
extend@3.0.2: {}
extract-zip@2.0.1(supports-color@8.1.1):
@@ -5287,6 +5530,10 @@ snapshots:
dependencies:
pend: 1.2.0
fdir@6.4.5(picomatch@4.0.2):
optionalDependencies:
picomatch: 4.0.2
figures@3.2.0:
dependencies:
escape-string-regexp: 1.0.5
@@ -5669,6 +5916,8 @@ snapshots:
slice-ansi: 4.0.0
wrap-ansi: 6.2.0
loupe@3.1.3: {}
lru-cache@10.4.3: {}
lucide-vue-next@0.378.0(vue@3.5.13(typescript@5.7.3)):
@@ -5858,6 +6107,10 @@ snapshots:
path-type@4.0.0: {}
pathe@2.0.3: {}
pathval@2.0.0: {}
pause-stream@0.0.11:
dependencies:
through: 2.3.8
@@ -5877,6 +6130,8 @@ snapshots:
picomatch@2.3.1: {}
picomatch@4.0.2: {}
pify@2.3.0: {}
pinia@2.3.0(typescript@5.7.3)(vue@3.5.13(typescript@5.7.3)):
@@ -6156,30 +6411,30 @@ snapshots:
robust-predicates@3.0.2: {}
rollup@4.40.1:
rollup@4.41.1:
dependencies:
'@types/estree': 1.0.7
optionalDependencies:
'@rollup/rollup-android-arm-eabi': 4.40.1
'@rollup/rollup-android-arm64': 4.40.1
'@rollup/rollup-darwin-arm64': 4.40.1
'@rollup/rollup-darwin-x64': 4.40.1
'@rollup/rollup-freebsd-arm64': 4.40.1
'@rollup/rollup-freebsd-x64': 4.40.1
'@rollup/rollup-linux-arm-gnueabihf': 4.40.1
'@rollup/rollup-linux-arm-musleabihf': 4.40.1
'@rollup/rollup-linux-arm64-gnu': 4.40.1
'@rollup/rollup-linux-arm64-musl': 4.40.1
'@rollup/rollup-linux-loongarch64-gnu': 4.40.1
'@rollup/rollup-linux-powerpc64le-gnu': 4.40.1
'@rollup/rollup-linux-riscv64-gnu': 4.40.1
'@rollup/rollup-linux-riscv64-musl': 4.40.1
'@rollup/rollup-linux-s390x-gnu': 4.40.1
'@rollup/rollup-linux-x64-gnu': 4.40.1
'@rollup/rollup-linux-x64-musl': 4.40.1
'@rollup/rollup-win32-arm64-msvc': 4.40.1
'@rollup/rollup-win32-ia32-msvc': 4.40.1
'@rollup/rollup-win32-x64-msvc': 4.40.1
'@rollup/rollup-android-arm-eabi': 4.41.1
'@rollup/rollup-android-arm64': 4.41.1
'@rollup/rollup-darwin-arm64': 4.41.1
'@rollup/rollup-darwin-x64': 4.41.1
'@rollup/rollup-freebsd-arm64': 4.41.1
'@rollup/rollup-freebsd-x64': 4.41.1
'@rollup/rollup-linux-arm-gnueabihf': 4.41.1
'@rollup/rollup-linux-arm-musleabihf': 4.41.1
'@rollup/rollup-linux-arm64-gnu': 4.41.1
'@rollup/rollup-linux-arm64-musl': 4.41.1
'@rollup/rollup-linux-loongarch64-gnu': 4.41.1
'@rollup/rollup-linux-powerpc64le-gnu': 4.41.1
'@rollup/rollup-linux-riscv64-gnu': 4.41.1
'@rollup/rollup-linux-riscv64-musl': 4.41.1
'@rollup/rollup-linux-s390x-gnu': 4.41.1
'@rollup/rollup-linux-x64-gnu': 4.41.1
'@rollup/rollup-linux-x64-musl': 4.41.1
'@rollup/rollup-win32-arm64-msvc': 4.41.1
'@rollup/rollup-win32-ia32-msvc': 4.41.1
'@rollup/rollup-win32-x64-msvc': 4.41.1
fsevents: 2.3.3
rope-sequence@1.3.4: {}
@@ -6245,6 +6500,8 @@ snapshots:
side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2
siginfo@2.0.0: {}
signal-exit@3.0.7: {}
signal-exit@4.1.0: {}
@@ -6297,6 +6554,8 @@ snapshots:
safer-buffer: 2.1.2
tweetnacl: 0.14.5
stackback@0.0.2: {}
start-server-and-test@2.0.9:
dependencies:
arg: 5.0.2
@@ -6310,6 +6569,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
std-env@3.9.0: {}
stream-combiner@0.0.4:
dependencies:
duplexer: 0.1.2
@@ -6345,7 +6606,7 @@ snapshots:
stylus@0.57.0:
dependencies:
css: 3.0.0
debug: 4.4.0(supports-color@8.1.1)
debug: 4.4.1
glob: 7.2.3
safer-buffer: 2.1.2
sax: 1.2.4
@@ -6438,8 +6699,23 @@ snapshots:
through@2.3.8: {}
tinybench@2.9.0: {}
tinyexec@0.3.2: {}
tinyglobby@0.2.14:
dependencies:
fdir: 6.4.5(picomatch@4.0.2)
picomatch: 4.0.2
tinypool@1.1.0: {}
tinyqueue@2.0.3: {}
tinyrainbow@2.0.0: {}
tinyspy@4.0.3: {}
tippy.js@6.3.7:
dependencies:
'@popperjs/core': 2.11.8
@@ -6528,17 +6804,73 @@ snapshots:
core-util-is: 1.0.2
extsprintf: 1.3.0
vite@5.4.18(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0):
vite-node@3.2.2(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0):
dependencies:
cac: 6.7.14
debug: 4.4.1
es-module-lexer: 1.7.0
pathe: 2.0.3
vite: 5.4.19(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)
transitivePeerDependencies:
- '@types/node'
- less
- lightningcss
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
vite@5.4.19(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0):
dependencies:
esbuild: 0.21.5
postcss: 8.4.49
rollup: 4.40.1
rollup: 4.41.1
optionalDependencies:
'@types/node': 22.10.5
fsevents: 2.3.3
sass: 1.83.1
stylus: 0.57.0
vitest@3.2.2(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0):
dependencies:
'@types/chai': 5.2.2
'@vitest/expect': 3.2.2
'@vitest/mocker': 3.2.2(vite@5.4.19(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0))
'@vitest/pretty-format': 3.2.2
'@vitest/runner': 3.2.2
'@vitest/snapshot': 3.2.2
'@vitest/spy': 3.2.2
'@vitest/utils': 3.2.2
chai: 5.2.0
debug: 4.4.1
expect-type: 1.2.1
magic-string: 0.30.17
pathe: 2.0.3
picomatch: 4.0.2
std-env: 3.9.0
tinybench: 2.9.0
tinyexec: 0.3.2
tinyglobby: 0.2.14
tinypool: 1.1.0
tinyrainbow: 2.0.0
vite: 5.4.19(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)
vite-node: 3.2.2(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 22.10.5
transitivePeerDependencies:
- less
- lightningcss
- msw
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
vt-pbf@3.1.3:
dependencies:
'@mapbox/point-geometry': 0.1.0
@@ -6634,6 +6966,11 @@ snapshots:
dependencies:
isexe: 2.0.0
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
stackback: 0.0.2
word-wrap@1.2.5: {}
wrap-ansi@6.2.0:

View File

@@ -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,7 +106,7 @@
<Command />
<!-- Create conversation dialog -->
<CreateConversation v-model="openCreateConversationDialog" />
<CreateConversation v-model="openCreateConversationDialog" v-if="openCreateConversationDialog" />
</template>
<script setup>
@@ -122,6 +148,7 @@ import {
SidebarMenuItem,
SidebarProvider
} from '@/components/ui/sidebar'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import SidebarNavUser from '@/components/sidebar/SidebarNavUser.vue'
const route = useRoute()
@@ -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

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

@@ -13,12 +13,20 @@
min-height: 100%;
overflow: hidden;
margin: 0;
@apply bg-background text-foreground;
}
@media (max-width: 768px) {
@media (max-width: 768px) {
html,
body {
overflow-x: auto;
}
}
* {
@apply border-border;
}
.native-html {
p {
margin-bottom: 0.5rem;
@@ -215,37 +223,6 @@
}
}
.dot-loader {
display: inline-flex;
align-items: center;
}
.dot {
width: 4px;
height: 4px;
border-radius: 50%;
background-color: currentColor;
margin: 0 2px;
animation: dot-flashing 1s infinite linear alternate;
}
.dot:nth-child(2) {
animation-delay: 0.2s;
}
.dot:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes dot-flashing {
0% {
opacity: 0.2;
}
100% {
opacity: 1;
}
}
[data-radix-popper-content-wrapper] {
z-index: 9999 !important;
}
@@ -256,12 +233,3 @@
@apply text-blue-500 hover:underline;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

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 '@/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 '@/components/ui/avatar'
import ComboBox from '@/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

@@ -30,25 +30,23 @@
<Button
size="sm"
variant="ghost"
@click.prevent="isBold = !isBold"
:active="isBold"
:class="{ 'bg-gray-200 dark:bg-secondary': 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 dark:bg-secondary': 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"
@click.prevent="editor?.chain().focus().toggleBulletList().run()"
:class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('bulletList') }"
>
<List size="14" />
@@ -57,7 +55,7 @@
<Button
size="sm"
variant="ghost"
@click.prevent="toggleOrderedList"
@click.prevent="editor?.chain().focus().toggleOrderedList().run()"
:class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('orderedList') }"
>
<ListOrdered 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,
@@ -121,21 +119,18 @@ import TableRow from '@tiptap/extension-table-row'
import TableCell from '@tiptap/extension-table-cell'
import TableHeader from '@tiptap/extension-table-header'
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: () => []
@@ -146,8 +141,6 @@ const emit = defineEmits(['send', 'aiPromptSelected'])
const emitPrompt = (key) => emit('aiPromptSelected', key)
const getSelectionText = (from, to, doc) => doc.textBetween(from, to)
// To preseve the table styling in emails, need to set the table style inline.
// Created these custom extensions to set the table style inline.
const CustomTable = Table.extend({
@@ -156,7 +149,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;'
}
}
}
@@ -169,7 +162,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;'
}
}
}
@@ -182,26 +175,27 @@ 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) => {
@@ -209,110 +203,30 @@ const editorConfig = {
emit('send')
return true
}
if (event.ctrlKey && event.key.toLowerCase() === 'b') {
// Prevent outer listeners
event.stopPropagation()
return 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()
},
// To update state when user types.
onUpdate: ({ editor }) => {
isInternalUpdate.value = true
htmlContent.value = editor.getHTML()
textContent.value = editor.getText()
isInternalUpdate.value = false
}
})
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) => {
@@ -324,18 +238,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>
@@ -146,12 +121,12 @@ import {
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { Plus, X } from 'lucide-vue-next'
import { Plus } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { useI18n } from 'vue-i18n'
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
import CloseButton from '@/components/button/CloseButton.vue'
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
const props = defineProps({
fields: {

View File

@@ -11,7 +11,7 @@
</template>
<script setup>
import { defineProps, defineEmits } from 'vue'
import { defineEmits } from 'vue'
const props = defineProps({
title: String,

View File

@@ -30,7 +30,7 @@ import {
Search,
Plus,
CircleDashed,
List,
List
} from 'lucide-vue-next'
import {
DropdownMenu,
@@ -40,7 +40,7 @@ import {
} from '@/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'
@@ -54,6 +54,14 @@ const route = useRoute()
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')
}
@@ -70,14 +78,27 @@ const filteredAdminNavItems = computed(() => filterNavItems(adminNavItems, userS
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)
@@ -111,7 +132,11 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
<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>
@@ -135,7 +160,7 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
<SidebarMenuItem>
<div class="px-1">
<span class="font-semibold text-xl">
{{ t('navigation.reports') }}
{{ t('globals.terms.report', 2) }}
</span>
</div>
</SidebarMenuItem>
@@ -166,7 +191,7 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
<SidebarMenuItem>
<div class="flex flex-col items-start justify-between w-full px-1">
<span class="font-semibold text-xl">
{{ t('navigation.admin') }}
{{ t('globals.terms.admin') }}
</span>
<!-- App version -->
<div class="text-xs text-muted-foreground">
@@ -193,11 +218,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"
/>
@@ -231,7 +257,7 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
<SidebarMenuItem>
<div class="px-1">
<span class="font-semibold text-xl">
{{ t('navigation.account') }}
{{ t('globals.terms.account') }}
</span>
</div>
</SidebarMenuItem>
@@ -265,7 +291,7 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
<SidebarMenuItem>
<div class="flex items-center justify-between w-full px-1">
<div class="font-semibold text-xl">
<span>{{ t('navigation.inbox') }}</span>
<span>{{ t('globals.terms.inbox') }}</span>
</div>
<div class="mr-1 mt-1 hover:scale-110 transition-transform">
<router-link :to="{ name: 'search' }">
@@ -298,7 +324,7 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
<SidebarMenuButton asChild :isActive="isActiveParent('/inboxes/assigned')">
<router-link :to="{ name: 'inbox', params: { type: 'assigned' } }">
<User />
<span>{{ t('navigation.myInbox') }}</span>
<span>{{ t('globals.terms.myInbox') }}</span>
</router-link>
</SidebarMenuButton>
</SidebarMenuItem>
@@ -308,7 +334,7 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
<router-link :to="{ name: 'inbox', params: { type: 'unassigned' } }">
<CircleDashed />
<span>
{{ t('navigation.unassigned') }}
{{ t('globals.terms.unassigned') }}
</span>
</router-link>
</SidebarMenuButton>
@@ -338,7 +364,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"
@@ -372,7 +398,7 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
<router-link to="#" class="group/item !p-2">
<!-- <SlidersHorizontal /> -->
<span>
{{ t('navigation.views') }}
{{ t('globals.terms.view', 2) }}
</span>
<div>
<Plus
@@ -406,10 +432,10 @@ 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>

View File

@@ -96,7 +96,7 @@
<DropdownMenuGroup>
<DropdownMenuItem @click.prevent="router.push({ name: 'account' })">
<CircleUserRound size="18" class="mr-2" />
{{ t('navigation.account') }}
{{ t('globals.terms.account') }}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />

View File

@@ -10,13 +10,30 @@
>
{{ header }}
</th>
<th scope="col" class="relative px-6 py-3"></th>
<th v-if="showDelete" scope="col" class="relative px-6 py-3"></th>
</tr>
</thead>
<tbody class="bg-background divide-y divide-border">
<template v-if="data.length === 0">
<!-- 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 + 1" class="px-6 py-12 text-center">
<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">
{{
@@ -29,6 +46,8 @@
</td>
</tr>
</template>
<!-- Data Rows -->
<template v-else>
<tr v-for="(item, index) in data" :key="index" class="hover:bg-accent">
<td
@@ -51,8 +70,9 @@
<script setup>
import { Trash2 } from 'lucide-vue-next'
import { defineProps, defineEmits } from 'vue'
import { defineEmits } from 'vue'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
defineProps({
headers: {
@@ -73,6 +93,14 @@ defineProps({
showDelete: {
type: Boolean,
default: true
},
loading: {
type: Boolean,
default: false
},
skeletonRows: {
type: Number,
default: 5
}
})

View File

@@ -1,23 +1,39 @@
<script setup>
import { Primitive } from 'reka-ui';
import { cn } from '@/lib/utils';
import { buttonVariants } from '.';
import { Primitive } from 'reka-ui'
import { cn } from '@/lib/utils'
import { buttonVariants } from '.'
import { Loader2 } from 'lucide-vue-next'
const props = defineProps({
variant: { type: null, required: false },
size: { type: null, required: false },
class: { type: null, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false, default: 'button' },
});
isLoading: { type: Boolean, required: false, default: false },
disabled: { type: Boolean, required: false, default: false }
})
</script>
<template>
<Primitive
:as="as"
:as-child="asChild"
:class="cn(buttonVariants({ variant, size }), props.class)"
:class="
cn(
buttonVariants({ variant, size }),
'relative',
{ 'text-transparent': isLoading },
props.class
)
"
:disabled="isLoading || disabled"
>
<slot />
<span
v-if="isLoading"
class="absolute inset-0 flex items-center justify-center pointer-events-none text-background"
>
<Loader2 class="h-5 w-5 animate-spin" />
</span>
</Primitive>
</template>

View File

@@ -0,0 +1,116 @@
<template>
<div class="flex items-center gap-2">
<Select v-model="selectedDays" @update:model-value="handleFilterChange">
<SelectTrigger class="w-[140px] h-8 text-xs">
<SelectValue
:placeholder="
t('globals.messages.select', {
name: t('globals.terms.day', 2)
})
"
/>
</SelectTrigger>
<SelectContent class="text-xs">
<SelectItem value="0">{{ $t('globals.terms.today') }}</SelectItem>
<SelectItem value="1">
{{
$t('globals.messages.lastNItems', {
n: 1,
name: t('globals.terms.day', 1).toLowerCase()
})
}}
</SelectItem>
<SelectItem value="2">
{{
$t('globals.messages.lastNItems', {
n: 2,
name: t('globals.terms.day', 2).toLowerCase()
})
}}
</SelectItem>
<SelectItem value="7">
{{
$t('globals.messages.lastNItems', {
n: 7,
name: t('globals.terms.day', 2).toLowerCase()
})
}}
</SelectItem>
<SelectItem value="30">
{{
$t('globals.messages.lastNItems', {
n: 30,
name: t('globals.terms.day', 2).toLowerCase()
})
}}
</SelectItem>
<SelectItem value="90">
{{
$t('globals.messages.lastNItems', {
n: 90,
name: t('globals.terms.day', 2).toLowerCase()
})
}}
</SelectItem>
<SelectItem value="custom">
{{
$t('globals.messages.custom', {
name: t('globals.terms.day', 2).toLowerCase()
})
}}
</SelectItem>
</SelectContent>
</Select>
<div v-if="selectedDays === 'custom'" class="flex items-center gap-2">
<Input
v-model="customDaysInput"
type="number"
min="1"
max="365"
class="w-20 h-8"
@blur="handleCustomDaysChange"
@keyup.enter="handleCustomDaysChange"
/>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { Input } from '@/components/ui/input'
const { t } = useI18n()
const emit = defineEmits(['filterChange'])
const selectedDays = ref('30')
const customDaysInput = ref('')
const handleFilterChange = (value) => {
if (value === 'custom') {
customDaysInput.value = '30'
emit('filterChange', 30)
} else {
emit('filterChange', parseInt(value))
}
}
const handleCustomDaysChange = () => {
const days = parseInt(customDaysInput.value)
if (days && days > 0 && days <= 365) {
emit('filterChange', days)
} else {
customDaysInput.value = '30'
emit('filterChange', 30)
}
}
handleFilterChange(selectedDays.value)
</script>

View File

@@ -0,0 +1 @@
export { default as DateFilter } from './DateFilter.vue'

View File

@@ -1,7 +1,11 @@
<template>
<span class="dot-loader">
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
<span class="inline-flex items-center">
<span class="w-1 h-1 rounded-full bg-current mx-0.5 animate-dot-flashing"></span>
<span
class="w-1 h-1 rounded-full bg-current mx-0.5 animate-dot-flashing [animation-delay:0.2s]"
></span>
<span
class="w-1 h-1 rounded-full bg-current mx-0.5 animate-dot-flashing [animation-delay:0.4s]"
></span>
</span>
</template>

View File

@@ -3,7 +3,7 @@
<!-- Tags visible to the user -->
<div class="flex gap-2 flex-wrap items-center px-3">
<TagsInputItem v-for="tagValue in tags" :key="tagValue" :value="tagValue">
<TagsInputItemText/>
<TagsInputItemText />
<TagsInputItemDelete />
</TagsInputItem>
</div>
@@ -23,6 +23,7 @@
:class="tags.length > 0 ? 'mt-2' : ''"
@keydown.enter.prevent
@blur="handleBlur"
@click="open = true"
/>
</ComboboxInput>
</ComboboxAnchor>
@@ -99,11 +100,14 @@ const open = ref(false)
const searchTerm = ref('')
// Get all options that are not already selected and match the search term
// If not search term is provided, return all available options
const filteredOptions = computed(() => {
return props.items.filter(
(item) =>
!tags.value.includes(item.value) &&
item.label.toLowerCase().includes(searchTerm.value.toLowerCase())
const available = props.items.filter((item) => !tags.value.includes(item.value))
if (!searchTerm.value) return available
return available.filter((item) =>
item.label.toLowerCase().includes(searchTerm.value.toLowerCase())
)
})
@@ -127,6 +131,8 @@ const handleSelect = (event) => {
// Custom filter function to filter items based on the search term
const filterFunc = (remainingItemValues, term) => {
const remainingItems = props.items.filter((item) => remainingItemValues.includes(item.value))
return remainingItems.filter((item) => item.label.toLowerCase().includes(term.toLowerCase())).map(item => item.value)
return remainingItems
.filter((item) => item.label.toLowerCase().includes(term.toLowerCase()))
.map((item) => item.value)
}
</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 { 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

@@ -6,6 +6,7 @@ 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 '@/composables/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,150 +1,160 @@
export const reportsNavItems = [
{
titleKey: 'navigation.overview',
href: '/reports/overview',
permission: 'reports:manage'
}
{
titleKey: 'globals.terms.overview',
href: '/reports/overview',
permission: 'reports:manage'
}
]
export const adminNavItems = [
{
titleKey: 'navigation.workspace',
children: [
{
titleKey: 'navigation.generalSettings',
href: '/admin/general',
permission: 'general_settings:manage'
},
{
titleKey: 'navigation.businessHours',
href: '/admin/business-hours',
permission: 'business_hours:manage'
},
{
titleKey: 'navigation.slaPolicies',
href: '/admin/sla',
permission: 'sla:manage'
}
]
},
{
titleKey: 'navigation.conversations',
children: [
{
titleKey: 'navigation.tags',
href: '/admin/conversations/tags',
permission: 'tags:manage'
},
{
titleKey: 'navigation.macros',
href: '/admin/conversations/macros',
permission: 'macros:manage'
},
{
titleKey: 'navigation.statuses',
href: '/admin/conversations/statuses',
permission: 'status:manage'
}
]
},
{
titleKey: 'navigation.inboxes',
children: [
{
titleKey: 'navigation.inboxes',
href: '/admin/inboxes',
permission: 'inboxes:manage'
}
]
},
{
titleKey: 'navigation.teammates',
children: [
{
titleKey: 'navigation.agents',
href: '/admin/teams/agents',
permission: 'users:manage'
},
{
titleKey: 'navigation.teams',
href: '/admin/teams/teams',
permission: 'teams:manage'
},
{
titleKey: 'navigation.roles',
href: '/admin/teams/roles',
permission: 'roles:manage'
},
{
titleKey: 'navigation.activityLog',
href: '/admin/teams/activity-log',
permission: 'activity_logs:manage'
}
]
},
{
titleKey: 'navigation.automations',
children: [
{
titleKey: 'navigation.automations',
href: '/admin/automations',
permission: 'automations:manage'
}
]
},
{
titleKey: 'navigation.customAttributes',
children: [
{
titleKey: 'navigation.customAttributes',
href: '/admin/custom-attributes',
permission: 'custom_attributes:manage'
}
]
},
{
titleKey: 'navigation.notifications',
children: [
{
titleKey: 'navigation.email',
href: '/admin/notification',
permission: 'notification_settings:manage'
}
]
},
{
titleKey: 'navigation.templates',
children: [
{
titleKey: 'navigation.templates',
href: '/admin/templates',
permission: 'templates:manage'
}
]
},
{
titleKey: 'navigation.security',
children: [
{
titleKey: 'navigation.sso',
href: '/admin/sso',
permission: 'oidc:manage'
}
]
},
{
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: 'navigation.profile',
href: '/account/profile',
description: 'Update your profile'
}
{
titleKey: 'globals.terms.profile',
href: '/account/profile'
}
]
export const contactNavItems = [
{
titleKey: 'navigation.allContacts',
href: '/contacts',
}
]
{
titleKey: 'globals.terms.contact',
href: '/contacts'
}
]

View File

@@ -1,41 +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',
};
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

@@ -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,7 +148,6 @@
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { Skeleton } from '@/components/ui/skeleton'
import SimpleTable from '@/components/table/SimpleTable.vue'
import {
Pagination,

View File

@@ -24,7 +24,7 @@
<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('form.field.lastActive') }}</p>
<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
@@ -37,7 +37,7 @@
<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('form.field.lastLogin') }}</p>
<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
@@ -52,10 +52,128 @@
</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('form.field.firstName') }}</FormLabel>
<FormLabel>{{ $t('globals.terms.firstName') }}</FormLabel>
<FormControl>
<Input type="text" placeholder="" v-bind="field" />
</FormControl>
@@ -65,7 +183,7 @@
<FormField v-slot="{ field }" name="last_name">
<FormItem>
<FormLabel>{{ $t('form.field.lastName') }}</FormLabel>
<FormLabel>{{ $t('globals.terms.lastName') }}</FormLabel>
<FormControl>
<Input type="text" placeholder="" v-bind="field" />
</FormControl>
@@ -75,7 +193,7 @@
<FormField v-slot="{ field }" name="email">
<FormItem v-auto-animate>
<FormLabel>{{ $t('form.field.email') }}</FormLabel>
<FormLabel>{{ $t('globals.terms.email') }}</FormLabel>
<FormControl>
<Input type="email" placeholder="" v-bind="field" />
</FormControl>
@@ -85,11 +203,11 @@
<FormField v-slot="{ componentField, handleChange }" name="teams">
<FormItem v-auto-animate>
<FormLabel>{{ $t('form.field.teams') }}</FormLabel>
<FormLabel>{{ $t('globals.terms.team', 2) }}</FormLabel>
<FormControl>
<SelectTag
:items="teamOptions"
:placeholder="t('form.field.selectTeams')"
:placeholder="t('globals.messages.select', { name: t('globals.terms.team', 2) })"
v-model="componentField.modelValue"
@update:modelValue="handleChange"
/>
@@ -100,11 +218,15 @@
<FormField v-slot="{ componentField, handleChange }" name="roles">
<FormItem v-auto-animate>
<FormLabel>{{ $t('form.field.roles') }}</FormLabel>
<FormLabel>{{ $t('globals.terms.role', 2) }}</FormLabel>
<FormControl>
<SelectTag
:items="roleOptions"
:placeholder="t('form.field.selectRoles')"
:placeholder="
t('globals.messages.select', {
name: $t('globals.terms.role', 2)
})
"
v-model="componentField.modelValue"
@update:modelValue="handleChange"
/>
@@ -115,14 +237,14 @@
<FormField v-slot="{ componentField }" name="availability_status" v-if="!isNewForm">
<FormItem>
<FormLabel>{{ t('form.field.availabilityStatus') }}</FormLabel>
<FormLabel>{{ t('globals.terms.availabilityStatus') }}</FormLabel>
<FormControl>
<Select v-bind="componentField" v-model="componentField.modelValue">
<SelectTrigger>
<SelectValue
:placeholder="
t('form.field.select', {
name: t('form.field.availabilityStatus')
t('globals.messages.select', {
name: t('globals.terms.availabilityStatus')
})
"
/>
@@ -132,7 +254,7 @@
<SelectItem value="active_group">{{ t('globals.terms.active') }}</SelectItem>
<SelectItem value="away_manual">{{ t('globals.terms.away') }}</SelectItem>
<SelectItem value="away_and_reassigning">
{{ t('form.field.awayReassigning') }}
{{ t('globals.terms.awayReassigning') }}
</SelectItem>
</SelectGroup>
</SelectContent>
@@ -144,7 +266,7 @@
<FormField v-slot="{ field }" name="new_password" v-if="!isNewForm">
<FormItem v-auto-animate>
<FormLabel>{{ t('form.field.setPassword') }}</FormLabel>
<FormLabel>{{ t('globals.terms.setPassword') }}</FormLabel>
<FormControl>
<Input type="password" placeholder="" v-bind="field" />
</FormControl>
@@ -157,7 +279,7 @@
<FormControl>
<div class="flex items-center space-x-2">
<Checkbox :checked="value" @update:checked="handleChange" />
<Label>{{ $t('form.field.sendWelcomeEmail') }}</Label>
<Label>{{ $t('globals.terms.sendWelcomeEmail') }}</Label>
</div>
</FormControl>
<FormMessage />
@@ -170,7 +292,7 @@
<Checkbox :checked="value" @update:checked="handleChange" />
</FormControl>
<div class="space-y-1 leading-none">
<FormLabel> {{ $t('form.field.enabled') }} </FormLabel>
<FormLabel> {{ $t('globals.terms.enabled') }} </FormLabel>
<FormMessage />
</div>
</FormItem>
@@ -190,7 +312,7 @@ import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import { vAutoAnimate } from '@formkit/auto-animate/vue'
import { Badge } from '@/components/ui/badge'
import { Clock, LogIn } from 'lucide-vue-next'
import { Clock, LogIn, Key, RotateCcw, Trash2, Plus, Copy, AlertTriangle } from 'lucide-vue-next'
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import {
@@ -203,7 +325,18 @@ import {
} from '@/components/ui/select'
import { SelectTag } from '@/components/ui/select'
import { Input } from '@/components/ui/input'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { useI18n } from 'vue-i18n'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { format } from 'date-fns'
import api from '@/api'
@@ -234,6 +367,19 @@ const props = defineProps({
const { t } = useI18n()
const teams = ref([])
const roles = ref([])
const emitter = useEmitter()
const apiKeyData = ref({
api_key: props.initialValues?.api_key || '',
api_secret: ''
})
const apiKeyLastUsedAt = ref(props.initialValues?.api_key_last_used_at || null)
const newAPIKeyData = ref({
api_key: '',
api_secret: ''
})
const showAPIKeyDialog = ref(false)
const isAPIKeyLoading = ref(false)
onMounted(async () => {
try {
@@ -241,7 +387,10 @@ onMounted(async () => {
teams.value = teamsResp.value.data.data
roles.value = rolesResp.value.data.data
} catch (err) {
console.log(err)
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: t('globals.messages.errorFetching')
})
}
})
@@ -250,7 +399,7 @@ const availabilityStatus = computed(() => {
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('form.field.awayReassigning'), color: 'bg-orange-500' }
return { text: t('globals.terms.awayReassigning'), color: 'bg-orange-500' }
return { text: t('globals.terms.offline'), color: 'bg-gray-400' }
})
@@ -280,6 +429,87 @@ const getInitials = (firstName, lastName) => {
return `${firstName.charAt(0).toUpperCase()}${lastName.charAt(0).toUpperCase()}`
}
const generateAPIKey = async () => {
if (!props.initialValues?.id) return
try {
isAPIKeyLoading.value = true
const response = await api.generateAPIKey(props.initialValues.id)
if (response.data) {
const responseData = response.data.data
newAPIKeyData.value = {
api_key: responseData.api_key,
api_secret: responseData.api_secret
}
apiKeyData.value.api_key = responseData.api_key
// Clear the last used timestamp since this is a new API key
apiKeyLastUsedAt.value = null
showAPIKeyDialog.value = true
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
description: t('globals.messages.generatedSuccessfully', {
name: t('globals.terms.apiKey')
})
})
}
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: t('globals.messages.errorGenerating', {
name: t('globals.terms.apiKey')
})
})
} finally {
isAPIKeyLoading.value = false
}
}
const regenerateAPIKey = async () => {
await generateAPIKey()
}
const revokeAPIKey = async () => {
if (!props.initialValues?.id) return
try {
isAPIKeyLoading.value = true
await api.revokeAPIKey(props.initialValues.id)
apiKeyData.value.api_key = ''
apiKeyData.value.api_secret = ''
apiKeyLastUsedAt.value = null
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
description: t('globals.messages.revokedSuccessfully', {
name: t('globals.terms.apiKey')
})
})
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: t('globals.messages.errorRevoking', {
name: t('globals.terms.apiKey')
})
})
} finally {
isAPIKeyLoading.value = false
}
}
const 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) => {
@@ -298,6 +528,10 @@ watch(
'teams',
newValues.teams.map((team) => team.name)
)
// Update API key data
apiKeyData.value.api_key = newValues.api_key || ''
apiKeyLastUsedAt.value = newValues.api_key_last_used_at || null
}, 0)
}
},

View File

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

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

@@ -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>
@@ -133,7 +88,7 @@
<script setup>
import { toRefs } from 'vue'
import { Button } from '@/components/ui/button'
import { X } from 'lucide-vue-next'
import CloseButton from '@/components/button/CloseButton.vue'
import { useTagStore } from '@/stores/tag'
import {
Select,
@@ -143,13 +98,12 @@ import {
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'
import { useI18n } from 'vue-i18n'
import Editor from '@/features/conversation/ConversationTextEditor.vue'
import Editor from '@/components/editor/TextEditor.vue'
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
const props = defineProps({
actions: {

View File

@@ -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">
@@ -242,6 +193,7 @@ import { toRefs, computed, watch } from 'vue'
import { Checkbox } from '@/components/ui/checkbox'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Button } from '@/components/ui/button'
import CloseButton from '@/components/button/CloseButton.vue'
import {
Select,
SelectContent,
@@ -258,13 +210,11 @@ import {
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'
import { useI18n } from 'vue-i18n'
import { useConversationFilters } from '@/composables/useConversationFilters'
import SelectComboBox from '@/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>

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

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" />
@@ -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.saveChanges') }}
</Button>
</DialogFooter>
</DialogContent>
@@ -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>

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>

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(

View File

@@ -8,10 +8,10 @@
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="editCustomAttribute">
{{ $t('globals.buttons.edit') }}
{{ $t('globals.messages.edit') }}
</DropdownMenuItem>
<DropdownMenuItem @click="() => (alertOpen = true)">
{{ $t('globals.buttons.delete') }}
{{ $t('globals.messages.delete') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -21,13 +21,15 @@
<AlertDialogHeader>
<AlertDialogTitle>{{ $t('globals.messages.areYouAbsolutelySure') }}</AlertDialogTitle>
<AlertDialogDescription>{{
$t('admin.customAttributes.deleteConfirmation')
$t('globals.messages.deletionConfirmation', {
name: $t('globals.terms.customAttribute').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>

View File

@@ -15,7 +15,7 @@
<FormField v-slot="{ componentField }" name="lang">
<FormItem>
<FormLabel>{{ t('admin.general.language') }}</FormLabel>
<FormLabel>{{ t('globals.terms.language') }}</FormLabel>
<FormControl>
<Select v-bind="componentField" :modelValue="componentField.modelValue">
<SelectTrigger>
@@ -39,7 +39,7 @@
<FormField v-slot="{ componentField }" name="timezone">
<FormItem>
<FormLabel>
{{ t('admin.general.timezone') }}
{{ t('globals.terms.timezone') }}
</FormLabel>
<FormControl>
<Select v-bind="componentField">
@@ -91,7 +91,7 @@
<FormField v-slot="{ field }" name="root_url">
<FormItem>
<FormLabel>
{{ t('admin.general.rootURL') }}
{{ t('globals.terms.rootURL') }}
</FormLabel>
<FormControl>
<Input type="text" placeholder="" v-bind="field" />
@@ -230,7 +230,7 @@ const props = defineProps({
}
})
const submitLabel = props.submitLabel || t('globals.buttons.save')
const submitLabel = props.submitLabel || t('globals.messages.save')
const form = useForm({
validationSchema: toTypedSchema(createFormSchema(t))
})
@@ -248,18 +248,10 @@ const fetchBusinessHours = async () => {
})
businessHours.value = response.data.data
} catch (error) {
// If unauthorized (no permission), show a toast message.
if (error.response.status === 403) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: t('admin.businessHours.unauthorized')
})
} else {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
}
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
}
}

View File

@@ -3,7 +3,7 @@
<!-- Basic Fields -->
<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>
@@ -14,7 +14,7 @@
<FormField v-slot="{ componentField }" name="from">
<FormItem>
<FormLabel>{{ $t('form.field.fromEmailAddress') }}</FormLabel>
<FormLabel>{{ $t('globals.terms.fromEmailAddress') }}</FormLabel>
<FormControl>
<Input
type="text"
@@ -33,7 +33,7 @@
<FormField v-slot="{ componentField, handleChange }" name="enabled">
<FormItem class="flex flex-row items-center justify-between box p-4">
<div class="space-y-0.5">
<FormLabel class="text-base">{{ $t('form.field.enabled') }}</FormLabel>
<FormLabel class="text-base">{{ $t('globals.terms.enabled') }}</FormLabel>
<FormDescription>{{ $t('admin.inbox.enabled.description') }}</FormDescription>
</div>
<FormControl>
@@ -73,7 +73,7 @@
<FormField v-slot="{ componentField }" name="imap.port">
<FormItem>
<FormLabel>{{ $t('form.field.port') }}</FormLabel>
<FormLabel>{{ $t('globals.terms.port') }}</FormLabel>
<FormControl>
<Input type="number" placeholder="993" v-bind="componentField" />
</FormControl>
@@ -100,7 +100,7 @@
<FormField v-slot="{ componentField }" name="imap.username">
<FormItem>
<FormLabel>{{ $t('form.field.username') }}</FormLabel>
<FormLabel>{{ $t('globals.terms.username') }}</FormLabel>
<FormControl>
<Input type="text" placeholder="inbox@example.com" v-bind="componentField" />
</FormControl>
@@ -110,7 +110,7 @@
<FormField v-slot="{ componentField }" name="imap.password">
<FormItem>
<FormLabel>{{ $t('form.field.password') }}</FormLabel>
<FormLabel>{{ $t('globals.terms.password') }}</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" v-bind="componentField" />
</FormControl>
@@ -124,7 +124,7 @@
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue :placeholder="t('form.field.selectTLS')" />
<SelectValue :placeholder="t('globals.messages.selectTLS')" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">OFF</SelectItem>
@@ -185,7 +185,7 @@
<FormField v-slot="{ componentField }" name="smtp.host">
<FormItem>
<FormLabel>{{ $t('form.field.host') }}</FormLabel>
<FormLabel>{{ $t('globals.terms.host') }}</FormLabel>
<FormControl>
<Input type="text" placeholder="smtp.gmail.com" v-bind="componentField" />
</FormControl>
@@ -195,7 +195,7 @@
<FormField v-slot="{ componentField }" name="smtp.port">
<FormItem>
<FormLabel>{{ $t('form.field.port') }}</FormLabel>
<FormLabel>{{ $t('globals.terms.port') }}</FormLabel>
<FormControl>
<Input type="number" placeholder="587" v-bind="componentField" />
</FormControl>
@@ -205,7 +205,7 @@
<FormField v-slot="{ componentField }" name="smtp.username">
<FormItem>
<FormLabel>{{ $t('form.field.username') }}</FormLabel>
<FormLabel>{{ $t('globals.terms.username') }}</FormLabel>
<FormControl>
<Input type="text" placeholder="user@example.com" v-bind="componentField" />
</FormControl>
@@ -215,7 +215,7 @@
<FormField v-slot="{ componentField }" name="smtp.password">
<FormItem>
<FormLabel>{{ $t('form.field.password') }}</FormLabel>
<FormLabel>{{ $t('globals.terms.password') }}</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" v-bind="componentField" />
</FormControl>
@@ -296,7 +296,7 @@
<FormField v-slot="{ componentField }" name="smtp.tls_type">
<FormItem>
<FormLabel>{{ t('admin.inbox.tls') }}</FormLabel>
<FormLabel>{{ t('globals.terms.tls') }}</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
@@ -398,7 +398,7 @@ const form = useForm({
initialValues: {
name: '',
from: '',
enabled: false,
enabled: true,
csat_enabled: false,
imap: {
host: 'imap.gmail.com',
@@ -429,7 +429,7 @@ const form = useForm({
})
const submitLabel = computed(() => {
return props.submitLabel || t('globals.buttons.save')
return props.submitLabel || t('globals.messages.save')
})
const onSubmit = form.handleSubmit(async (values) => {

View File

@@ -8,16 +8,16 @@
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="editInbox(props.inbox.id)">{{
$t('globals.buttons.edit')
$t('globals.messages.edit')
}}</DropdownMenuItem>
<DropdownMenuItem @click="() => (alertOpen = true)">{{
$t('globals.buttons.delete')
$t('globals.messages.delete')
}}</DropdownMenuItem>
<DropdownMenuItem @click="toggleInbox(props.inbox.id)" v-if="props.inbox.enabled">
{{ $t('globals.buttons.disable') }}
{{ $t('globals.messages.disable') }}
</DropdownMenuItem>
<DropdownMenuItem @click="toggleInbox(props.inbox.id)" v-else>{{
$t('globals.buttons.enable')
$t('globals.messages.enable')
}}</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -27,13 +27,13 @@
<AlertDialogHeader>
<AlertDialogTitle>{{ $t('globals.messages.areYouAbsolutelySure') }}</AlertDialogTitle>
<AlertDialogDescription>
{{ $t('admin.inbox.deleteConfirmation') }}
{{ $t('globals.messages.deletionConfirmation', { name: $t('globals.terms.inbox').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>

View File

@@ -1,125 +1,136 @@
<template>
<div class="space-y-5 rounded">
<div class="space-y-5">
<div v-for="(action, index) in model" :key="index" class="space-y-5">
<hr v-if="index" class="border-t-2 border-dotted border-gray-300" />
<div class="space-y-6">
<!-- Empty State -->
<div
v-if="!model.length"
class="text-center py-12 px-6 border-2 border-dashed border-muted rounded-lg"
>
<div class="mx-auto w-12 h-12 bg-muted rounded-full flex items-center justify-center mb-3">
<Plus class="w-6 h-6 text-muted-foreground" />
</div>
<h3 class="text-sm font-medium text-foreground mb-2">
{{ $t('globals.messages.no', { name: $t('globals.terms.action', 2).toLowerCase() }) }}
</h3>
<Button
@click.prevent="add"
variant="outline"
size="sm"
class="inline-flex items-center gap-2"
>
<Plus class="w-4 h-4" />
{{ config.addButtonText }}
</Button>
</div>
<div class="space-y-3">
<div class="flex items-center justify-between">
<div class="flex gap-5">
<div class="w-48">
<Select
v-model="action.type"
@update:modelValue="(value) => updateField(value, index)"
<!-- Actions List -->
<div v-else class="space-y-6">
<div v-for="(action, index) in model" :key="index" class="relative">
<!-- Action Card -->
<div class="border rounded p-6 shadow-sm hover:shadow-md transition-shadow">
<div class="flex items-start justify-between gap-4">
<div class="flex-1 space-y-4">
<!-- Action Type Selection -->
<div class="flex flex-col sm:flex-row gap-4">
<div class="flex-1 max-w-xs">
<label class="block text-sm font-medium mb-2">{{
$t('globals.messages.type', {
name: $t('globals.terms.action')
})
}}</label>
<Select
v-model="action.type"
@update:modelValue="(value) => updateField(value, index)"
>
<SelectTrigger class="w-full">
<SelectValue :placeholder="config.typePlaceholder" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem
v-for="(actionConfig, key) in config.actions"
:key="key"
:value="key"
>
{{ actionConfig.label }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
<!-- Value Selection -->
<div
v-if="action.type && config.actions[action.type]?.type === 'select'"
class="flex-1 max-w-xs"
>
<SelectTrigger>
<SelectValue :placeholder="config.typePlaceholder" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem
v-for="(actionConfig, key) in config.actions"
:key="key"
:value="key"
>
{{ actionConfig.label }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<label class="block text-sm font-medium mb-2">Value</label>
<SelectComboBox
v-if="action.type === 'assign_user'"
v-model="action.value[0]"
:items="config.actions[action.type].options"
:placeholder="config.valuePlaceholder"
@update:modelValue="(value) => updateValue(value, index)"
type="user"
/>
<SelectComboBox
v-else-if="action.type === 'assign_team'"
v-model="action.value[0]"
:items="config.actions[action.type].options"
:placeholder="config.valuePlaceholder"
@update:modelValue="(value) => updateValue(value, index)"
type="team"
/>
<SelectComboBox
v-else
v-model="action.value[0]"
:items="config.actions[action.type].options"
:placeholder="config.valuePlaceholder"
@update:modelValue="(value) => updateValue(value, index)"
/>
</div>
</div>
<!-- Tag Selection -->
<div
v-if="action.type && config.actions[action.type]?.type === 'select'"
class="w-48"
v-if="action.type && config.actions[action.type]?.type === 'tag'"
class="max-w-md"
>
<ComboBox
v-model="action.value[0]"
:items="config.actions[action.type].options"
:placeholder="config.valuePlaceholder"
@update:modelValue="(value) => updateValue(value, index)"
>
<template #item="{ item }">
<div v-if="action.type === 'assign_user'">
<div class="flex items-center flex-1 gap-2 ml-2">
<Avatar 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>{{ item.label }}</span>
</div>
</div>
<div v-else-if="action.type === 'assign_team'">
<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>
<template #selected="{ selected }">
<div v-if="action.type === 'assign_user'">
<div 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>
</div>
<div v-else-if="action.type === 'assign_team'">
<div class="flex items-center gap-2">
<span v-if="selected">
{{ selected.emoji }}
<span>{{ selected.label }}</span>
</span>
<span v-else>
{{ $t('form.field.selectTeam') }}
</span>
</div>
</div>
<div v-else-if="selected">
{{ selected.label }}
</div>
<div v-else>{{ $t('form.field.select') }}</div>
</template>
</ComboBox>
<label class="block text-sm font-medium mb-2">{{ $t('globals.terms.tag') }}</label>
<SelectTag
v-model="action.value"
:items="tagsStore.tagNames.map((tag) => ({ label: tag, value: tag }))"
placeholder="Select tags"
/>
</div>
</div>
<X class="cursor-pointer w-4" @click="remove(index)" />
</div>
<div v-if="action.type && config.actions[action.type]?.type === 'tag'">
<SelectTag
v-model="action.value"
:items="tagsStore.tagNames.map((tag) => ({ label: tag, value: tag }))"
placeholder="Select tag"
/>
<!-- Remove Button -->
<CloseButton :onClose="() => remove(index)" />
</div>
</div>
</div>
<!-- Add Action Button -->
<div class="flex justify-center pt-2">
<Button
type="button"
variant="outline"
@click="add"
class="inline-flex items-center gap-2 border-dashed hover:border-solid"
>
<Plus class="w-4 h-4" />
{{ config.addButtonText }}
</Button>
</div>
</div>
<Button type="button" variant="outline" @click.prevent="add">{{ config.addButtonText }}</Button>
</div>
</template>
<script setup>
import { Button } from '@/components/ui/button'
import { X } from 'lucide-vue-next'
import { Plus } from 'lucide-vue-next'
import {
Select,
SelectContent,
@@ -128,10 +139,10 @@ import {
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import CloseButton from '@/components/button/CloseButton.vue'
import { SelectTag } from '@/components/ui/select'
import { useTagStore } from '@/stores/tag'
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
const model = defineModel('actions', {
type: Array,

View File

@@ -3,7 +3,7 @@
<form @submit="onSubmit" class="space-y-6 w-full" :class="{ 'opacity-50': formLoading }">
<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>
@@ -19,7 +19,7 @@
<Editor
v-model:htmlContent="componentField.modelValue"
@update:htmlContent="(value) => componentField.onChange(value)"
:placeholder="t('editor.placeholder')"
:placeholder="t('editor.newLine')"
/>
</div>
</FormControl>
@@ -27,9 +27,16 @@
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="actions">
<FormField
v-slot="{ componentField }"
name="actions"
:validate-on-blur="false"
:validate-on-change="false"
>
<FormItem>
<FormLabel> {{ t('admin.macro.actions') }}</FormLabel>
<FormLabel>
{{ t('globals.terms.action', 2) }} ({{ t('globals.terms.optional', 1).toLowerCase() }})
</FormLabel>
<FormControl>
<ActionBuilder
v-model:actions="componentField.modelValue"
@@ -41,19 +48,59 @@
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="visibility">
<FormField v-slot="{ componentField, handleChange }" name="visible_when">
<FormItem>
<FormLabel>{{ t('admin.macro.visibility') }}</FormLabel>
<FormLabel>{{ t('globals.messages.visibleWhen') }}</FormLabel>
<FormControl>
<SelectTag
:items="[
{ label: t('globals.messages.replying'), value: 'replying' },
{
label: t('globals.messages.starting', {
name: t('globals.terms.conversation').toLowerCase()
}),
value: 'starting_conversation'
},
{
label: t('globals.messages.adding', {
name: t('globals.terms.privateNote', 2).toLowerCase()
}),
value: 'adding_private_note'
}
]"
v-model="componentField.modelValue"
@update:modelValue="handleChange"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField
v-slot="{ componentField }"
name="visibility"
:validate-on-blur="false"
:validate-on-change="false"
:validate-on-input="false"
:validate-on-mount="false"
:validate-on-model-update="false"
>
<FormItem>
<FormLabel>{{ t('globals.terms.visibility') }}</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue placeholder="Select visibility" />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="all">{{ t('admin.macro.visibility.all') }}</SelectItem>
<SelectItem value="all">{{
t('globals.messages.all', {
name: t('globals.terms.agent', 2).toLowerCase()
})
}}</SelectItem>
<SelectItem value="team">{{ t('globals.terms.team') }}</SelectItem>
<SelectItem value="user">{{ t('globals.terms.user') }}</SelectItem>
<SelectItem value="user">{{ t('globals.terms.agent') }}</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
@@ -64,29 +111,16 @@
<FormField v-if="form.values.visibility === 'team'" v-slot="{ componentField }" name="team_id">
<FormItem>
<FormLabel>{{ t('globals.terms.user') }}</FormLabel>
<FormLabel>{{ t('globals.terms.team') }}</FormLabel>
<FormControl>
<ComboBox
<SelectComboBox
v-bind="componentField"
:items="tStore.options"
:placeholder="t('form.field.selectTeam')"
>
<template #item="{ item }">
<div class="flex items-center gap-2 ml-2">
<span>{{ item.emoji }}</span>
<span>{{ item.label }}</span>
</div>
</template>
<template #selected="{ selected }">
<div class="flex items-center gap-2">
<span v-if="selected">
{{ selected.emoji }}
<span>{{ selected.label }}</span>
</span>
<span v-else>{{ t('form.field.selectTeam') }}</span>
</div>
</template>
</ComboBox>
:placeholder="
t('globals.messages.select', { name: t('globals.terms.team').toLowerCase() })
"
type="team"
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -94,35 +128,16 @@
<FormField v-if="form.values.visibility === 'user'" v-slot="{ componentField }" name="user_id">
<FormItem>
<FormLabel>{{ t('globals.terms.user') }}</FormLabel>
<FormLabel>{{ t('globals.terms.agent') }}</FormLabel>
<FormControl>
<ComboBox
<SelectComboBox
v-bind="componentField"
:items="uStore.options"
:placeholder="t('form.field.selectUser')"
>
<template #item="{ item }">
<div class="flex items-center gap-2 ml-2">
<Avatar 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>{{ item.label }}</span>
</div>
</template>
<template #selected="{ selected }">
<div 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>
</template>
</ComboBox>
:placeholder="
t('globals.messages.select', { name: t('globals.terms.agent').toLowerCase() })
"
type="user"
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -139,24 +154,24 @@ import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/spinner'
import { Input } from '@/components/ui/input'
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import ActionBuilder from '@/features/admin/macros/ActionBuilder.vue'
import { useConversationFilters } from '@/composables/useConversationFilters'
import { useUsersStore } from '@/stores/users'
import { useTeamStore } from '@/stores/team'
import { getTextFromHTML } from '@/utils/strings.js'
import { createFormSchema } from './formSchema.js'
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
SelectValue,
SelectTag
} from '@/components/ui/select'
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
import { useI18n } from 'vue-i18n'
import Editor from '@/features/conversation/ConversationTextEditor.vue'
import Editor from '@/components/editor/TextEditor.vue'
const { macroActions } = useConversationFilters()
const { t } = useI18n()
@@ -185,18 +200,28 @@ const props = defineProps({
const submitLabel = computed(() => {
return (
props.submitLabel ||
(props.initialValues.id ? t('globals.buttons.update') : t('globals.buttons.create'))
(props.initialValues.id ? t('globals.messages.update') : t('globals.messages.create'))
)
})
const form = useForm({
validationSchema: toTypedSchema(createFormSchema(t))
validationSchema: toTypedSchema(createFormSchema(t)),
initialValues: {
visible_when: props.initialValues.visible_when || [
'replying',
'starting_conversation',
'adding_private_note'
],
visibility: props.initialValues.visibility || 'all'
}
})
const actionConfig = ref({
actions: macroActions,
typePlaceholder: t('form.field.selectActionType'),
valuePlaceholder: t('form.field.selectValue'),
addButtonText: t('form.field.addNewAction')
typePlaceholder: t('globals.messages.select', { name: t('globals.terms.action').toLowerCase() }),
valuePlaceholder: t('globals.messages.select', { name: t('globals.terms.value').toLowerCase() }),
addButtonText: t('globals.messages.new', {
name: t('globals.terms.action').toLowerCase()
})
})
const onSubmit = form.handleSubmit(async (values) => {

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: 'visibility',
header: function () {
return h('div', { class: 'text-center' }, t('admin.macro.visibility'))
return h('div', { class: 'text-center' }, t('globals.terms.visibility'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center' }, row.getValue('visibility'))
@@ -24,7 +24,7 @@ export const createColumns = (t) => [
{
accessorKey: 'usage_count',
header: function () {
return h('div', { class: 'text-center' }, t('form.field.usage'))
return h('div', { class: 'text-center' }, t('globals.terms.usage'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center' }, row.getValue('usage_count'))
@@ -33,7 +33,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' }, format(row.getValue('created_at'), 'PPpp'))
@@ -42,7 +42,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' }, format(row.getValue('updated_at'), 'PPpp'))

View File

@@ -7,9 +7,9 @@
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="editMacro">{{ $t('globals.buttons.edit') }}</DropdownMenuItem>
<DropdownMenuItem @click="editMacro">{{ $t('globals.messages.edit') }}</DropdownMenuItem>
<DropdownMenuItem @click="() => (isDeleteOpen = true)">
{{ $t('globals.buttons.delete') }}
{{ $t('globals.messages.delete') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -19,13 +19,13 @@
<AlertDialogHeader>
<AlertDialogTitle>{{ $t('globals.messages.areYouAbsolutelySure') }}</AlertDialogTitle>
<AlertDialogDescription>
{{ $t('admin.macro.deleteConfirmation') }}
{{ $t('globals.messages.deletionConfirmation', { name: $t('globals.terms.macro') }) }}
</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>

View File

@@ -1,10 +1,10 @@
import * as z from 'zod'
import { getTextFromHTML } from '@/utils/strings.js'
const actionSchema = (t) => z.array(
const actionSchema = () => z.array(
z.object({
type: z.string().min(1, t('admin.macro.actionTypeRequired')),
value: z.array(z.string().min(1, t('admin.macro.actionValueRequired'))),
type: z.string().optional(),
value: z.array(z.string()).optional(),
})
)
@@ -13,6 +13,7 @@ export const createFormSchema = (t) => z.object({
message_content: z.string().optional(),
actions: actionSchema(t).optional().default([]),
visibility: z.enum(['all', 'team', 'user']),
visible_when: z.array(z.enum(['replying', 'starting_conversation', 'adding_private_note'])),
team_id: z.string().nullable().optional(),
user_id: z.string().nullable().optional(),
})
@@ -34,19 +35,39 @@ export const createFormSchema = (t) => z.object({
.refine(
(data) => {
// If visibility is 'team', team_id is required
if (data.visibility === 'team' && !data.team_id) {
return false
if (data.visibility === 'team') {
return !!data.team_id
}
// If visibility is 'user', user_id is required
if (data.visibility === 'user' && !data.user_id) {
return false
}
// Otherwise, validation passes
return true
},
{
message: t('admin.macro.teamOrUserRequired'),
message: t('globals.messages.required'),
path: ['team_id'],
}
)
.refine(
(data) => {
// If visibility is 'user', user_id is required
if (data.visibility === 'user') {
return !!data.user_id
}
return true
},
{
message: t('globals.messages.required'),
path: ['user_id'],
}
).refine(
(data) => {
// if actions are present, all actions should have type and value defined.
if (data.actions && data.actions.length > 0) {
return data.actions.every(action => action.type?.length > 0 && action.value?.length > 0)
}
return true
},
{
message: t('admin.macro.actionInvalid'),
// Field path to highlight
path: ['visibility'],
path: ['actions'],
}
)

View File

@@ -6,7 +6,7 @@
<FormControl>
<div class="flex items-center space-x-2">
<Checkbox :checked="value" @update:checked="handleChange" />
<Label>{{ $t('form.field.enabled') }}</Label>
<Label>{{ $t('globals.terms.enabled') }}</Label>
</div>
</FormControl>
<FormMessage />
@@ -16,7 +16,7 @@
<!-- SMTP Host Field -->
<FormField v-slot="{ componentField }" name="host">
<FormItem>
<FormLabel>{{ $t('form.field.smtpHost') }}</FormLabel>
<FormLabel>{{ $t('globals.terms.smtpHost') }}</FormLabel>
<FormControl>
<Input type="text" placeholder="smtp.gmail.com" v-bind="componentField" />
</FormControl>
@@ -27,7 +27,7 @@
<!-- SMTP Port Field -->
<FormField v-slot="{ componentField }" name="port">
<FormItem>
<FormLabel>{{ $t('form.field.smtpPort') }}</FormLabel>
<FormLabel>{{ $t('globals.terms.smtpPort') }}</FormLabel>
<FormControl>
<Input type="number" placeholder="587" v-bind="componentField" />
</FormControl>
@@ -38,7 +38,7 @@
<!-- Username Field -->
<FormField v-slot="{ componentField }" name="username">
<FormItem>
<FormLabel>{{ $t('form.field.username') }}</FormLabel>
<FormLabel>{{ $t('globals.terms.username') }}</FormLabel>
<FormControl>
<Input type="text" placeholder="admin@yourcompany.com" v-bind="componentField" />
</FormControl>
@@ -49,7 +49,7 @@
<!-- Password Field -->
<FormField v-slot="{ componentField }" name="password">
<FormItem>
<FormLabel>{{ $t('form.field.password') }}</FormLabel>
<FormLabel>{{ $t('globals.terms.password') }}</FormLabel>
<FormControl>
<Input type="password" placeholder="" v-bind="componentField" />
</FormControl>
@@ -135,7 +135,7 @@
<!-- Email Address Field -->
<FormField v-slot="{ componentField }" name="email_address">
<FormItem>
<FormLabel>{{ $t('form.field.fromEmailAddress') }}</FormLabel>
<FormLabel>{{ $t('globals.terms.fromEmailAddress') }}</FormLabel>
<FormControl>
<Input
type="text"
@@ -169,7 +169,7 @@
<FormControl>
<Select v-bind="componentField" v-model="componentField.modelValue">
<SelectTrigger>
<SelectValue :placeholder="t('form.field.selectTLS')" />
<SelectValue :placeholder="t('globals.messages.selectTLS')" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
@@ -251,7 +251,7 @@ const submitLabel = computed(() => {
if (props.submitLabel) {
return props.submitLabel
}
return t('globals.buttons.save')
return t('globals.messages.save')
})
const smtpForm = useForm({

View File

@@ -11,7 +11,7 @@ export const createFormSchema = (t) => z.object({
}),
port: z
.number({
invalid_type_error: t('globals.messages.invalidPortNumber'),
invalid_type_error: t('globals.messages.invalidValue', { name: t('globals.terms.port') }),
required_error: t('globals.messages.required')
})
.min(1, { message: t('form.error.minmaxNumber', { min: 1, max: 65535 }) })

View File

@@ -2,11 +2,11 @@
<form @submit="onSubmit" class="space-y-6">
<FormField v-slot="{ componentField }" name="provider">
<FormItem>
<FormLabel>{{ $t('form.field.provider') }}</FormLabel>
<FormLabel>{{ $t('globals.terms.provider') }}</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue :placeholder="t('form.field.selectProvider')" />
<SelectValue :placeholder="t('globals.messages.select', { name: t('globals.terms.provider') })" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
@@ -22,7 +22,7 @@
<FormField v-slot="{ componentField }" name="name">
<FormItem v-auto-animate>
<FormLabel>{{ $t('form.field.name') }}</FormLabel>
<FormLabel>{{ $t('globals.terms.name') }}</FormLabel>
<FormControl>
<Input type="text" placeholder="Google" v-bind="componentField" />
</FormControl>
@@ -32,7 +32,7 @@
<FormField v-slot="{ componentField }" name="provider_url">
<FormItem v-auto-animate>
<FormLabel>{{ $t('form.field.providerURL') }}</FormLabel>
<FormLabel>{{ $t('globals.terms.providerURL') }}</FormLabel>
<FormControl>
<Input type="text" placeholder="https://accounts.google.com" v-bind="componentField" />
</FormControl>
@@ -42,7 +42,7 @@
<FormField v-slot="{ componentField }" name="client_id">
<FormItem v-auto-animate>
<FormLabel>{{ $t('form.field.clientID') }}</FormLabel>
<FormLabel>{{ $t('globals.terms.clientID') }}</FormLabel>
<FormControl>
<Input type="text" placeholder="" v-bind="componentField" />
</FormControl>
@@ -52,7 +52,7 @@
<FormField v-slot="{ componentField }" name="client_secret">
<FormItem v-auto-animate>
<FormLabel>{{ $t('form.field.clientSecret') }}</FormLabel>
<FormLabel>{{ $t('globals.terms.clientSecret') }}</FormLabel>
<FormControl>
<Input type="password" placeholder="" v-bind="componentField" />
</FormControl>
@@ -62,7 +62,7 @@
<FormField v-slot="{ componentField }" name="redirect_uri" v-if="!isNewForm">
<FormItem v-auto-animate>
<FormLabel>{{ $t('form.field.callbackURL') }}</FormLabel>
<FormLabel>{{ $t('globals.terms.callbackURL') }}</FormLabel>
<FormControl>
<Input type="text" placeholder="" v-bind="componentField" readonly />
</FormControl>
@@ -76,7 +76,7 @@
<FormControl>
<div class="flex items-center space-x-2">
<Checkbox :checked="value" @update:checked="handleChange" />
<Label>{{ $t('form.field.enabled') }}</Label>
<Label>{{ $t('globals.terms.enabled') }}</Label>
</div>
</FormControl>
<FormMessage />
@@ -139,7 +139,7 @@ const props = defineProps({
})
const { t } = useI18n()
const submitLabel = props.submitLabel || t('globals.buttons.save')
const submitLabel = props.submitLabel || t('globals.messages.save')
const form = useForm({
validationSchema: toTypedSchema(createFormSchema(t)),

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: 'provider',
header: function () {
return h('div', { class: 'text-center' }, t('form.field.provider'))
return h('div', { class: 'text-center' }, t('globals.terms.provider'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('provider'))
@@ -23,7 +23,7 @@ export const createColumns = (t) => [
},
{
accessorKey: 'enabled',
header: () => h('div', { class: 'text-center' }, t('form.field.enabled')),
header: () => h('div', { class: 'text-center' }, t('globals.terms.enabled')),
cell: ({ row }) => {
const enabled = row.getValue('enabled')
return h('div', { class: 'text-center' }, enabled ? t('globals.messages.yes') : t('globals.messages.no'))
@@ -32,7 +32,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' }, format(row.getValue('updated_at'), 'PPpp'))

View File

@@ -9,11 +9,11 @@
<DropdownMenuContent>
<DropdownMenuItem :as-child="true">
<RouterLink :to="{ name: 'edit-sso', params: { id: props.role.id } }">
{{ $t('globals.buttons.edit') }}
{{ $t('globals.messages.edit') }}
</RouterLink>
</DropdownMenuItem>
<DropdownMenuItem @click="() => (alertOpen = true)">{{
$t('globals.buttons.delete')
$t('globals.messages.delete')
}}</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -23,13 +23,13 @@
<AlertDialogHeader>
<AlertDialogTitle>{{ $t('globals.messages.areYouAbsolutelySure') }}</AlertDialogTitle>
<AlertDialogDescription>
{{ $t('admin.sso.deleteConfirmation') }}
{{ $t('globals.messages.deletionConfirmation', { name: $t('globals.terms.sso') }) }}
</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>

View File

@@ -2,7 +2,7 @@
<form @submit.prevent="onSubmit" class="space-y-8">
<FormField v-slot="{ componentField }" name="name">
<FormItem v-auto-animate>
<FormLabel>{{ $t('form.field.name') }}</FormLabel>
<FormLabel>{{ $t('globals.terms.name') }}</FormLabel>
<FormControl>
<Input type="text" :placeholder="t('globals.terms.agent')" v-bind="componentField" />
</FormControl>
@@ -11,7 +11,7 @@
</FormField>
<FormField v-slot="{ componentField }" name="description">
<FormItem>
<FormLabel>{{ $t('form.field.description') }}</FormLabel>
<FormLabel>{{ $t('globals.terms.description') }}</FormLabel>
<FormControl>
<Input
type="text"
@@ -99,7 +99,7 @@ const props = defineProps({
const { t } = useI18n()
const submitLabel = computed(() => {
return props.submitLabel || t('globals.buttons.save')
return props.submitLabel || t('globals.messages.save')
})
const permissions = ref([
@@ -166,7 +166,8 @@ const permissions = ref([
{ name: perms.SLA_MANAGE, label: t('admin.role.sla.manage') },
{ name: perms.AI_MANAGE, label: t('admin.role.ai.manage') },
{ name: perms.CUSTOM_ATTRIBUTES_MANAGE, label: t('admin.role.customAttributes.manage') },
{ name: perms.ACTIVITY_LOGS_MANAGE, label: t('admin.role.activityLog.manage') }
{ name: perms.ACTIVITY_LOGS_MANAGE, label: t('admin.role.activityLog.manage') },
{ name: perms.WEBHOOKS_MANAGE, label: t('admin.role.webhooks.manage') }
]
},
{

View File

@@ -5,7 +5,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'))
@@ -14,7 +14,7 @@ export const createColumns = (t) => [
{
accessorKey: 'description',
header: function () {
return h('div', { class: 'text-center' }, t('form.field.description'))
return h('div', { class: 'text-center' }, t('globals.terms.description'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('description'))

View File

@@ -8,13 +8,13 @@
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="editRole(props.role.id)">{{
$t('globals.buttons.edit')
$t('globals.messages.edit')
}}</DropdownMenuItem>
<DropdownMenuItem
@click="() => (alertOpen = true)"
v-if="Roles.includes(props.role.name) === false"
>
{{ $t('globals.buttons.delete') }}
{{ $t('globals.messages.delete') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -24,13 +24,17 @@
<AlertDialogHeader>
<AlertDialogTitle>{{ $t('globals.messages.areYouAbsolutelySure') }}</AlertDialogTitle>
<AlertDialogDescription>
{{ $t('admin.role.deleteConfirmation') }}
{{
$t('globals.messages.deletionConfirmation', {
name: $t('globals.terms.role').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>

View File

@@ -2,7 +2,7 @@
<form @submit="onSubmit" class="space-y-8">
<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>
@@ -12,7 +12,7 @@
<FormField v-slot="{ componentField }" name="description">
<FormItem>
<FormLabel>{{ t('form.field.description') }}</FormLabel>
<FormLabel>{{ t('globals.terms.description') }}</FormLabel>
<FormControl>
<Input type="text" placeholder="" v-bind="componentField" />
</FormControl>
@@ -227,10 +227,9 @@
</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger class="w-full">
<SelectValue
<SelectTrigger class="w-full"> <SelectValue
:placeholder="
t('form.field.select', {
t('globals.messages.select', {
name: t('globals.terms.slaMetric').toLowerCase()
})
"
@@ -338,7 +337,7 @@ const usersStore = useUsersStore()
const submitLabel = computed(() => {
return (
props.submitLabel ||
(props.initialValues.id ? t('globals.buttons.update') : t('globals.buttons.create'))
(props.initialValues.id ? t('globals.messages.update') : t('globals.messages.create'))
)
})

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

@@ -7,8 +7,12 @@
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="edit(props.role.id)">{{t('globals.buttons.edit')}}</DropdownMenuItem>
<DropdownMenuItem @click="() => (alertOpen = true)">{{t('globals.buttons.delete')}}</DropdownMenuItem>
<DropdownMenuItem @click="edit(props.role.id)">
{{ t('globals.messages.edit') }}
</DropdownMenuItem>
<DropdownMenuItem @click="() => (alertOpen = true)">
{{ t('globals.messages.delete') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -17,14 +21,18 @@
<AlertDialogHeader>
<AlertDialogTitle>{{ t('globals.messages.areYouAbsolutelySure') }}</AlertDialogTitle>
<AlertDialogDescription>
{{ t('globals.messages.delete', {
name: t('globals.terms.slaPolicy')
}) }}
{{
t('globals.messages.deletionConfirmation', {
name: t('globals.terms.slaPolicy').toLowerCase()
})
}}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{{ t('globals.buttons.cancel') }}</AlertDialogCancel>
<AlertDialogAction @click="handleDelete">{{t('globals.buttons.delete')}}</AlertDialogAction>
<AlertDialogCancel>{{ t('globals.messages.cancel') }}</AlertDialogCancel>
<AlertDialogAction @click="handleDelete">
{{ t('globals.messages.delete') }}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>

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