mirror of
				https://github.com/abhinavxd/libredesk.git
				synced 2025-11-04 05:53:30 +00:00 
			
		
		
		
	Compare commits
	
		
			227 Commits
		
	
	
		
			feat/dark-
			...
			main
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					879c626fb3 | ||
| 
						 | 
					16fbfa7b7c | ||
| 
						 | 
					b8da96c1d1 | ||
| 
						 | 
					3d76cce66a | ||
| 
						 | 
					4b8f30184a | ||
| 
						 | 
					e4018ddab8 | ||
| 
						 | 
					02e8a43587 | ||
| 
						 | 
					f3acc37405 | ||
| 
						 | 
					562babf222 | ||
| 
						 | 
					93e94432f5 | ||
| 
						 | 
					ec63604163 | ||
| 
						 | 
					f06da2a861 | ||
| 
						 | 
					98f16854c8 | ||
| 
						 | 
					cc36ef5a3a | ||
| 
						 | 
					969d6ea4f9 | ||
| 
						 | 
					326ccdf9d4 | ||
| 
						 | 
					d6a8e76472 | ||
| 
						 | 
					f95b374b74 | ||
| 
						 | 
					a1db6ccb31 | ||
| 
						 | 
					267a6027ee | ||
| 
						 | 
					3471263710 | ||
| 
						 | 
					7469e296d2 | ||
| 
						 | 
					44ffc77c4e | ||
| 
						 | 
					3ec061d8f1 | ||
| 
						 | 
					48b8d14f8f | ||
| 
						 | 
					6231a9e131 | ||
| 
						 | 
					d63302843b | ||
| 
						 | 
					a652f380b2 | ||
| 
						 | 
					a4a9a9ccd3 | ||
| 
						 | 
					71865e389e | ||
| 
						 | 
					ae470be4c8 | ||
| 
						 | 
					636742c34b | ||
| 
						 | 
					de77c03f66 | ||
| 
						 | 
					b7092744fd | ||
| 
						 | 
					6f300bb073 | ||
| 
						 | 
					a8ca12fb9a | ||
| 
						 | 
					e4bec993e6 | ||
| 
						 | 
					efc01be7d3 | ||
| 
						 | 
					ec72c5af90 | ||
| 
						 | 
					490417cf9d | ||
| 
						 | 
					4f54db3d1b | ||
| 
						 | 
					210b8bb53b | ||
| 
						 | 
					a0e1ccf117 | ||
| 
						 | 
					faf2082561 | ||
| 
						 | 
					50baa8491b | ||
| 
						 | 
					8e89e4e0d4 | ||
| 
						 | 
					b15413b7ca | ||
| 
						 | 
					701e5b2580 | ||
| 
						 | 
					dbd4e97f7e | ||
| 
						 | 
					007c332a7d | ||
| 
						 | 
					4fcad4fd81 | ||
| 
						 | 
					bece58bdec | ||
| 
						 | 
					6d2d8f78d4 | ||
| 
						 | 
					98492a1869 | ||
| 
						 | 
					18b50b11c8 | ||
| 
						 | 
					5a1628f710 | ||
| 
						 | 
					12ebe32ba3 | ||
| 
						 | 
					fce2587a9d | ||
| 
						 | 
					7d92ac9cce | ||
| 
						 | 
					3ce3c5e0ee | ||
| 
						 | 
					35ad00ec51 | ||
| 
						 | 
					9ec96be959 | ||
| 
						 | 
					6ca36d611f | ||
| 
						 | 
					5a87d24d72 | ||
| 
						 | 
					7d4e7e68c3 | ||
| 
						 | 
					5b941fd993 | ||
| 
						 | 
					63e348e512 | ||
| 
						 | 
					10a845dc81 | ||
| 
						 | 
					0228989202 | ||
| 
						 | 
					3f7d151d33 | ||
| 
						 | 
					a516773b14 | ||
| 
						 | 
					f6d3bd543f | ||
| 
						 | 
					074d147bb6 | ||
| 
						 | 
					c1c14f7f54 | ||
| 
						 | 
					634fc66e9f | ||
| 
						 | 
					78b8607d8f | ||
| 
						 | 
					0dec822c1c | ||
| 
						 | 
					958f5e38c0 | ||
| 
						 | 
					550a3fa801 | ||
| 
						 | 
					6bbfbe8cf6 | ||
| 
						 | 
					f9ed326d72 | ||
| 
						 | 
					e0dc0285a4 | ||
| 
						 | 
					b971619ea6 | ||
| 
						 | 
					69accaebef | ||
| 
						 | 
					27de73536e | ||
| 
						 | 
					df108a3363 | ||
| 
						 | 
					266c3dab72 | ||
| 
						 | 
					bf2c1fff6f | ||
| 
						 | 
					2930af0c4f | ||
| 
						 | 
					389c4e3dd3 | ||
| 
						 | 
					9a119e6dc3 | ||
| 
						 | 
					ee178d383d | ||
| 
						 | 
					fc4db676d9 | ||
| 
						 | 
					70cb3d0f80 | ||
| 
						 | 
					c9920c3377 | ||
| 
						 | 
					6d62c3a4ba | ||
| 
						 | 
					d9b5fb8f0f | ||
| 
						 | 
					3de320f1fb | ||
| 
						 | 
					be977dcff2 | ||
| 
						 | 
					5e19f13e18 | ||
| 
						 | 
					ccc5940dd9 | ||
| 
						 | 
					4203b82e90 | ||
| 
						 | 
					ba07e224c2 | ||
| 
						 | 
					3fff65150f | ||
| 
						 | 
					c4fcf6bd91 | ||
| 
						 | 
					5ea1b9e84c | ||
| 
						 | 
					5b522888bc | ||
| 
						 | 
					dc2250ce50 | ||
| 
						 | 
					839a06f0d2 | ||
| 
						 | 
					d2e5d85e3a | ||
| 
						 | 
					0737d22374 | ||
| 
						 | 
					d6af9d10ea | ||
| 
						 | 
					6381fc23c2 | ||
| 
						 | 
					6bb5728665 | ||
| 
						 | 
					2322ec33b0 | ||
| 
						 | 
					9132e11458 | ||
| 
						 | 
					e70f92d377 | ||
| 
						 | 
					591108f094 | ||
| 
						 | 
					1b2a5e4f36 | ||
| 
						 | 
					f613cc237b | ||
| 
						 | 
					c37258fccb | ||
| 
						 | 
					1879d9d22b | ||
| 
						 | 
					b369e2f56a | ||
| 
						 | 
					ef56f1a74e | ||
| 
						 | 
					d274adb19b | ||
| 
						 | 
					d31fcb00b6 | ||
| 
						 | 
					88d719ec4f | ||
| 
						 | 
					147180a536 | ||
| 
						 | 
					faa195f0a6 | ||
| 
						 | 
					4b0422d904 | ||
| 
						 | 
					9303997cea | ||
| 
						 | 
					aba07b3096 | ||
| 
						 | 
					27aac88f53 | ||
| 
						 | 
					cb6b0e420b | ||
| 
						 | 
					e004afd7d1 | ||
| 
						 | 
					6a77d346dc | ||
| 
						 | 
					60c89cb617 | ||
| 
						 | 
					b7d4b187e8 | ||
| 
						 | 
					2bf45f32de | ||
| 
						 | 
					981372ab86 | ||
| 
						 | 
					803196985d | ||
| 
						 | 
					ebf6a980e8 | ||
| 
						 | 
					813ef91964 | ||
| 
						 | 
					3b9fb7a08d | ||
| 
						 | 
					7fb86f140c | ||
| 
						 | 
					aa8d326fa1 | ||
| 
						 | 
					ca9a0a5892 | ||
| 
						 | 
					73e2950174 | ||
| 
						 | 
					e7b8e5c4bb | ||
| 
						 | 
					582c906440 | ||
| 
						 | 
					f3881ee0aa | ||
| 
						 | 
					b557c2ca4b | ||
| 
						 | 
					30884d3536 | ||
| 
						 | 
					bce0d1d12f | ||
| 
						 | 
					67a4f6a162 | ||
| 
						 | 
					ec28ac8f3a | ||
| 
						 | 
					bc71fcfdc1 | ||
| 
						 | 
					bc0bee8f6a | ||
| 
						 | 
					499fc0dad1 | ||
| 
						 | 
					03b932c1c0 | ||
| 
						 | 
					012de059e7 | ||
| 
						 | 
					6357faf6c8 | ||
| 
						 | 
					f7a12cffd3 | ||
| 
						 | 
					6487bf9a0a | ||
| 
						 | 
					53d5715429 | ||
| 
						 | 
					b561e79440 | ||
| 
						 | 
					e567acbe59 | ||
| 
						 | 
					57d0e90b5f | ||
| 
						 | 
					5a0e3a8072 | ||
| 
						 | 
					d95a5f40cf | ||
| 
						 | 
					6981a0790d | ||
| 
						 | 
					55bc9bfc91 | ||
| 
						 | 
					67db2e5ff2 | ||
| 
						 | 
					64304c2384 | ||
| 
						 | 
					c5fe6aaadd | ||
| 
						 | 
					fea7eef658 | ||
| 
						 | 
					475e400810 | ||
| 
						 | 
					641ae0540e | ||
| 
						 | 
					dc6fede081 | ||
| 
						 | 
					28dcd6cb2f | ||
| 
						 | 
					ade833fb7b | ||
| 
						 | 
					5bcb0a2ad9 | ||
| 
						 | 
					ad2f685fec | ||
| 
						 | 
					26c7df538c | ||
| 
						 | 
					625a08d0aa | ||
| 
						 | 
					bf1510b9c3 | ||
| 
						 | 
					bae896d38d | ||
| 
						 | 
					37b7c05b30 | ||
| 
						 | 
					eb05368f18 | ||
| 
						 | 
					7ef510894b | ||
| 
						 | 
					69268a3a84 | ||
| 
						 | 
					fcd3462d25 | ||
| 
						 | 
					fbf502451a | ||
| 
						 | 
					dc909ceb4f | ||
| 
						 | 
					cc1432b3e4 | ||
| 
						 | 
					d532a99771 | ||
| 
						 | 
					50baa3f38e | ||
| 
						 | 
					63a8f04408 | ||
| 
						 | 
					ea0b7d6d52 | ||
| 
						 | 
					5d6897a960 | ||
| 
						 | 
					c4a95672fe | ||
| 
						 | 
					2efd07b405 | ||
| 
						 | 
					0b9cf38826 | ||
| 
						 | 
					b44c314299 | ||
| 
						 | 
					2e1188e443 | ||
| 
						 | 
					afeec39b59 | ||
| 
						 | 
					fb2a08ec1a | ||
| 
						 | 
					7f2df0082c | ||
| 
						 | 
					6c523ac447 | ||
| 
						 | 
					02fc57c35a | ||
| 
						 | 
					cd0a357695 | ||
| 
						 | 
					2dc751e602 | ||
| 
						 | 
					8bc0cce993 | ||
| 
						 | 
					f6e2fc1956 | ||
| 
						 | 
					5fe5ac5882 | ||
| 
						 | 
					975577555d | ||
| 
						 | 
					f43acb77a1 | ||
| 
						 | 
					331c84fa56 | ||
| 
						 | 
					9314efb9d9 | ||
| 
						 | 
					5c8481af97 | ||
| 
						 | 
					d9bc4d1c0d | ||
| 
						 | 
					087c8ad491 | ||
| 
						 | 
					65cac843cb | ||
| 
						 | 
					23b0481f24 | ||
| 
						 | 
					9a651702ce | ||
| 
						 | 
					a0203f882e | ||
| 
						 | 
					75425ca0dd | 
							
								
								
									
										16
									
								
								.github/ISSUE_TEMPLATE/confirmed-bug.md
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								.github/ISSUE_TEMPLATE/confirmed-bug.md
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1,16 @@
 | 
				
			|||||||
 | 
					---
 | 
				
			||||||
 | 
					name: Confirmed Bug Report
 | 
				
			||||||
 | 
					about: Report a confirmed bug in Libredesk
 | 
				
			||||||
 | 
					title: "[Bug] <brief summary>"
 | 
				
			||||||
 | 
					labels: bug
 | 
				
			||||||
 | 
					assignees: ""
 | 
				
			||||||
 | 
					---
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Version:**
 | 
				
			||||||
 | 
					- libredesk: [eg: v0.7.0]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Description of the bug and steps to reproduce:**
 | 
				
			||||||
 | 
					A clear and concise description of what the bug is.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Logs / Screenshots:**
 | 
				
			||||||
 | 
					Attach any relevant logs or screenshots to help diagnose the issue.
 | 
				
			||||||
							
								
								
									
										16
									
								
								.github/ISSUE_TEMPLATE/possible-bug.md
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								.github/ISSUE_TEMPLATE/possible-bug.md
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1,16 @@
 | 
				
			|||||||
 | 
					---
 | 
				
			||||||
 | 
					name: Possible Bug Report
 | 
				
			||||||
 | 
					about: Something in Libredesk might be broken but needs confirmation
 | 
				
			||||||
 | 
					title: "[Possible Bug] <brief summary>"
 | 
				
			||||||
 | 
					labels: bug, needs-investigation
 | 
				
			||||||
 | 
					assignees: ""
 | 
				
			||||||
 | 
					---
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Version:**
 | 
				
			||||||
 | 
					 - libredesk: [eg: v0.7.0]
 | 
				
			||||||
 | 
					 
 | 
				
			||||||
 | 
					**Description of the bug and steps to reproduce:**
 | 
				
			||||||
 | 
					A clear and concise description of what the bug is.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Logs / Screenshots:**
 | 
				
			||||||
 | 
					Attach any relevant logs or screenshots to help diagnose the issue.
 | 
				
			||||||
							
								
								
									
										2
									
								
								.github/workflows/crowdin.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/crowdin.yml
									
									
									
									
										vendored
									
									
								
							@@ -12,6 +12,8 @@ on:
 | 
				
			|||||||
jobs:
 | 
					jobs:
 | 
				
			||||||
  crowdin:
 | 
					  crowdin:
 | 
				
			||||||
    runs-on: ubuntu-latest
 | 
					    runs-on: ubuntu-latest
 | 
				
			||||||
 | 
					    # Only run on the original repository, not forks
 | 
				
			||||||
 | 
					    if: github.event.repository.fork == false
 | 
				
			||||||
    steps:
 | 
					    steps:
 | 
				
			||||||
      - name: Checkout
 | 
					      - name: Checkout
 | 
				
			||||||
        uses: actions/checkout@v4
 | 
					        uses: actions/checkout@v4
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -53,6 +53,11 @@ jobs:
 | 
				
			|||||||
      - name: Configure app
 | 
					      - name: Configure app
 | 
				
			||||||
        run: |
 | 
					        run: |
 | 
				
			||||||
          cp config.sample.toml config.toml
 | 
					          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
 | 
					      - name: Install db schema and run tests
 | 
				
			||||||
        env:
 | 
					        env:
 | 
				
			||||||
							
								
								
									
										31
									
								
								.github/workflows/github-pages.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										31
									
								
								.github/workflows/github-pages.yml
									
									
									
									
										vendored
									
									
								
							@@ -1,31 +0,0 @@
 | 
				
			|||||||
name: Deploy MkDocs
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
on:
 | 
					 | 
				
			||||||
  push:
 | 
					 | 
				
			||||||
    branches:
 | 
					 | 
				
			||||||
      - main
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
jobs:
 | 
					 | 
				
			||||||
  deploy:
 | 
					 | 
				
			||||||
    runs-on: ubuntu-latest
 | 
					 | 
				
			||||||
    steps:
 | 
					 | 
				
			||||||
      - uses: actions/checkout@v3
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      - uses: actions/setup-python@v4
 | 
					 | 
				
			||||||
        with:
 | 
					 | 
				
			||||||
          python-version: 3.x
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      - run: pip install mkdocs-material
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      - run: |
 | 
					 | 
				
			||||||
          if [ -f requirements.txt ]; then
 | 
					 | 
				
			||||||
            pip install -r requirements.txt;
 | 
					 | 
				
			||||||
          fi
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      - run: cd docs && mkdocs build
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      - name: Deploy to GitHub Pages
 | 
					 | 
				
			||||||
        uses: peaceiris/actions-gh-pages@v3
 | 
					 | 
				
			||||||
        with:
 | 
					 | 
				
			||||||
          github_token: ${{ secrets.GITHUB_TOKEN }}
 | 
					 | 
				
			||||||
          publish_dir: ./docs/site
 | 
					 | 
				
			||||||
							
								
								
									
										6
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										6
									
								
								Makefile
									
									
									
									
									
								
							@@ -38,7 +38,7 @@ frontend-build: install-deps
 | 
				
			|||||||
.PHONY: run-backend
 | 
					.PHONY: run-backend
 | 
				
			||||||
run-backend:
 | 
					run-backend:
 | 
				
			||||||
	@echo "→ Running backend..."
 | 
						@echo "→ Running backend..."
 | 
				
			||||||
	CGO_ENABLED=0 go run -ldflags="-s -w -X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}' -X 'main.frontendDir=frontend/dist'" cmd/*.go
 | 
						CGO_ENABLED=0 go run -ldflags="-s -w -X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}' -X 'github.com/abhinavxd/libredesk/internal/version.Version=${VERSION}' -X 'main.frontendDir=frontend/dist'" cmd/*.go
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Run the JS frontend server in development mode.
 | 
					# Run the JS frontend server in development mode.
 | 
				
			||||||
.PHONY: run-frontend
 | 
					.PHONY: run-frontend
 | 
				
			||||||
@@ -52,8 +52,8 @@ run-frontend:
 | 
				
			|||||||
.PHONY: build-backend
 | 
					.PHONY: build-backend
 | 
				
			||||||
build-backend: $(STUFFBIN)
 | 
					build-backend: $(STUFFBIN)
 | 
				
			||||||
	@echo "→ Building backend..."
 | 
						@echo "→ Building backend..."
 | 
				
			||||||
	@CGO_ENABLED=0 go build -a\
 | 
						@CGO_ENABLED=0 go build -a \
 | 
				
			||||||
		-ldflags="-X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}' -s -w" \
 | 
							-ldflags="-X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}' -X 'github.com/abhinavxd/libredesk/internal/version.Version=${VERSION}' -s -w" \
 | 
				
			||||||
		-o ${BIN} cmd/*.go
 | 
							-o ${BIN} cmd/*.go
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Main build target: builds both frontend and backend, then stuffs static assets into the binary.
 | 
					# Main build target: builds both frontend and backend, then stuffs static assets into the binary.
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										35
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										35
									
								
								README.md
									
									
									
									
									
								
							@@ -3,20 +3,17 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
# Libredesk
 | 
					# Libredesk
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Open source, self-hosted customer support desk. Single binary app.
 | 
					Modern, open source, self-hosted customer support desk. Single binary app. 
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Visit [libredesk.io](https://libredesk.io) for more info. Check out the [**Live demo**](https://demo.libredesk.io/).
 | 
					Visit [libredesk.io](https://libredesk.io) for more info. Check out the [**Live demo**](https://demo.libredesk.io/).
 | 
				
			||||||
 | 
					
 | 
				
			||||||

 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
> **CAUTION:** This project is currently in **alpha**. Features and APIs may change and are not yet fully tested.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
## Features
 | 
					## Features
 | 
				
			||||||
 | 
					
 | 
				
			||||||
- **Multi Inbox**  
 | 
					- **Multi Shared Inbox**  
 | 
				
			||||||
  Libredesk supports multiple inboxes, letting you manage conversations across teams effortlessly.
 | 
					  Libredesk supports multiple shared inboxes, letting you manage conversations across teams effortlessly.
 | 
				
			||||||
- **Granular Permissions**  
 | 
					- **Granular Permissions**  
 | 
				
			||||||
  Create custom roles with granular permissions for teams and individual agents.
 | 
					  Create custom roles with granular permissions for teams and individual agents.
 | 
				
			||||||
- **Smart Automation**  
 | 
					- **Smart Automation**  
 | 
				
			||||||
@@ -31,14 +28,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.
 | 
					  Distribute workload with auto assignment rules. Auto-assign conversations based on agent capacity or custom criteria.
 | 
				
			||||||
- **SLA Management**  
 | 
					- **SLA Management**  
 | 
				
			||||||
  Set and track response time targets. Get notified when conversations are at risk of breaching SLA commitments.
 | 
					  Set and track response time targets. Get notified when conversations are at risk of breaching SLA commitments.
 | 
				
			||||||
- **Business Intelligence**  
 | 
					- **Custom attributes**  
 | 
				
			||||||
  Connect your favorite BI tools like Metabase and create custom dashboards and reports with your support data—without lock-ins.
 | 
					  Create custom attributes for contacts or conversations such as the subscription plan or the date of their first purchase. 
 | 
				
			||||||
- **AI-Assisted Response Rewrite**  
 | 
					- **AI-Assist**  
 | 
				
			||||||
  Instantly rewrite responses with AI to make them more friendly, professional, or polished.
 | 
					  Instantly rewrite responses with AI to make them more friendly, professional, or polished.
 | 
				
			||||||
- **Activity logs**  
 | 
					- **Activity logs**  
 | 
				
			||||||
  Track all actions performed by agents and admins—updates and key events across the system—for auditing and accountability.
 | 
					  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**  
 | 
					- **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)
 | 
					And more checkout - [libredesk.io](https://libredesk.io)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -57,8 +56,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.
 | 
					# Copy the config.sample.toml to config.toml and edit it as needed.
 | 
				
			||||||
cp config.sample.toml config.toml
 | 
					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.
 | 
					# Run the services in the background.
 | 
				
			||||||
docker compose up -d
 | 
					docker compose up -d
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -66,9 +63,9 @@ docker compose up -d
 | 
				
			|||||||
docker exec -it libredesk_app ./libredesk --set-system-user-password
 | 
					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/)
 | 
					See [installation docs](https://docs.libredesk.io/getting-started/installation)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
__________________
 | 
					__________________
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -79,12 +76,12 @@ __________________
 | 
				
			|||||||
- Run `./libredesk --set-system-user-password` to set the password for the System user.
 | 
					- Run `./libredesk --set-system-user-password` to set the password for the System user.
 | 
				
			||||||
- Run `./libredesk` and visit `http://localhost:9000` and login with username `System` and the password you set using the --set-system-user-password command.
 | 
					- Run `./libredesk` and visit `http://localhost:9000` and login with username `System` and the password you set using the --set-system-user-password command.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
See [installation docs](https://libredesk.io/docs/installation)
 | 
					See [installation docs](https://docs.libredesk.io/getting-started/installation)
 | 
				
			||||||
__________________
 | 
					__________________
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
## Developers
 | 
					## Developers
 | 
				
			||||||
If you are interested in contributing, refer to the [developer setup](https://libredesk.io/docs/developer-setup/). The backend is written in Go and the frontend is Vue js 3 with Shadcn for UI components.
 | 
					If you are interested in contributing, refer to the [developer setup](https://docs.libredesk.io/contributing/developer-setup). The backend is written in Go and the frontend is Vue js 3 with Shadcn for UI components.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
## Translators
 | 
					## Translators
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										17
									
								
								cmd/ai.go
									
									
									
									
									
								
							
							
						
						
									
										17
									
								
								cmd/ai.go
									
									
									
									
									
								
							@@ -5,6 +5,11 @@ import (
 | 
				
			|||||||
	"github.com/zerodha/fastglue"
 | 
						"github.com/zerodha/fastglue"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type aiCompletionReq struct {
 | 
				
			||||||
 | 
						PromptKey string `json:"prompt_key"`
 | 
				
			||||||
 | 
						Content   string `json:"content"`
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type providerUpdateReq struct {
 | 
					type providerUpdateReq struct {
 | 
				
			||||||
	Provider string `json:"provider"`
 | 
						Provider string `json:"provider"`
 | 
				
			||||||
	APIKey   string `json:"api_key"`
 | 
						APIKey   string `json:"api_key"`
 | 
				
			||||||
@@ -13,11 +18,15 @@ type providerUpdateReq struct {
 | 
				
			|||||||
// handleAICompletion handles AI completion requests
 | 
					// handleAICompletion handles AI completion requests
 | 
				
			||||||
func handleAICompletion(r *fastglue.Request) error {
 | 
					func handleAICompletion(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
		app       = r.Context.(*App)
 | 
							app = r.Context.(*App)
 | 
				
			||||||
		promptKey = string(r.RequestCtx.PostArgs().Peek("prompt_key"))
 | 
							req = aiCompletionReq{}
 | 
				
			||||||
		content   = string(r.RequestCtx.PostArgs().Peek("content"))
 | 
					 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
	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 {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -9,6 +9,10 @@ import (
 | 
				
			|||||||
	"github.com/zerodha/fastglue"
 | 
						"github.com/zerodha/fastglue"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type updateAutomationRuleExecutionModeReq struct {
 | 
				
			||||||
 | 
						Mode string `json:"mode"`
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// handleGetAutomationRules gets all automation rules
 | 
					// handleGetAutomationRules gets all automation rules
 | 
				
			||||||
func handleGetAutomationRules(r *fastglue.Request) error {
 | 
					func handleGetAutomationRules(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
@@ -41,10 +45,11 @@ func handleToggleAutomationRule(r *fastglue.Request) error {
 | 
				
			|||||||
		app   = r.Context.(*App)
 | 
							app   = r.Context.(*App)
 | 
				
			||||||
		id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
 | 
							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 sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return r.SendEnvelope(true)
 | 
						return r.SendEnvelope(toggledRule)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// handleUpdateAutomationRule updates an automation rule
 | 
					// 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)
 | 
							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 sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return r.SendEnvelope(true)
 | 
						return r.SendEnvelope(updatedRule)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// handleCreateAutomationRule creates a new automation rule
 | 
					// handleCreateAutomationRule creates a new automation rule
 | 
				
			||||||
@@ -77,10 +83,11 @@ func handleCreateAutomationRule(r *fastglue.Request) error {
 | 
				
			|||||||
	if err := r.Decode(&rule, "json"); err != nil {
 | 
						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)
 | 
							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 sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return r.SendEnvelope(true)
 | 
						return r.SendEnvelope(createdRule)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// handleDeleteAutomationRule deletes an automation rule
 | 
					// 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
 | 
					// handleUpdateAutomationRuleExecutionMode updates the execution mode of the automation rules for a given type
 | 
				
			||||||
func handleUpdateAutomationRuleExecutionMode(r *fastglue.Request) error {
 | 
					func handleUpdateAutomationRuleExecutionMode(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
		app  = r.Context.(*App)
 | 
							app = r.Context.(*App)
 | 
				
			||||||
		mode = string(r.RequestCtx.PostArgs().Peek("mode"))
 | 
							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)
 | 
							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.
 | 
						// 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 sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return r.SendEnvelope(true)
 | 
						return r.SendEnvelope(true)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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)
 | 
							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 sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	return r.SendEnvelope(true)
 | 
						return r.SendEnvelope(createdBusinessHours)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// handleDeleteBusinessHour deletes the business hour with the given id.
 | 
					// handleDeleteBusinessHour deletes the business hour with the given id.
 | 
				
			||||||
@@ -93,8 +94,9 @@ func handleUpdateBusinessHours(r *fastglue.Request) error {
 | 
				
			|||||||
	if businessHours.Name == "" {
 | 
						if businessHours.Name == "" {
 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`name`"), nil, envelope.InputError)
 | 
							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 sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return r.SendEnvelope(true)
 | 
						return r.SendEnvelope(updatedBusinessHours)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										63
									
								
								cmd/config.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								cmd/config.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,63 @@
 | 
				
			|||||||
 | 
					package main
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import (
 | 
				
			||||||
 | 
						"encoding/json"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						"github.com/abhinavxd/libredesk/internal/envelope"
 | 
				
			||||||
 | 
						"github.com/zerodha/fastglue"
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// handleGetConfig returns the public configuration needed for app initialization, this includes minimal app settings and enabled SSO providers (without secrets).
 | 
				
			||||||
 | 
					func handleGetConfig(r *fastglue.Request) error {
 | 
				
			||||||
 | 
						var app = r.Context.(*App)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Get app settings
 | 
				
			||||||
 | 
						settingsJSON, err := app.setting.GetByPrefix("app")
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Unmarshal settings
 | 
				
			||||||
 | 
						var settings map[string]any
 | 
				
			||||||
 | 
						if err := json.Unmarshal(settingsJSON, &settings); err != nil {
 | 
				
			||||||
 | 
							app.lo.Error("error unmarshalling settings", "err", err)
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorFetching", "name", app.i18n.T("globals.terms.setting")), nil))
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Filter to only include public fields needed for initial app load
 | 
				
			||||||
 | 
						publicSettings := map[string]any{
 | 
				
			||||||
 | 
							"app.lang":        settings["app.lang"],
 | 
				
			||||||
 | 
							"app.favicon_url": settings["app.favicon_url"],
 | 
				
			||||||
 | 
							"app.logo_url":    settings["app.logo_url"],
 | 
				
			||||||
 | 
							"app.site_name":   settings["app.site_name"],
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Get all OIDC providers
 | 
				
			||||||
 | 
						oidcProviders, err := app.oidc.GetAll()
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Filter for enabled providers and remove client_secret
 | 
				
			||||||
 | 
						enabledProviders := make([]map[string]any, 0)
 | 
				
			||||||
 | 
						for _, provider := range oidcProviders {
 | 
				
			||||||
 | 
							if provider.Enabled {
 | 
				
			||||||
 | 
								providerMap := map[string]any{
 | 
				
			||||||
 | 
									"id":           provider.ID,
 | 
				
			||||||
 | 
									"name":         provider.Name,
 | 
				
			||||||
 | 
									"provider":     provider.Provider,
 | 
				
			||||||
 | 
									"provider_url": provider.ProviderURL,
 | 
				
			||||||
 | 
									"client_id":    provider.ClientID,
 | 
				
			||||||
 | 
									"logo_url":     provider.ProviderLogoURL,
 | 
				
			||||||
 | 
									"enabled":      provider.Enabled,
 | 
				
			||||||
 | 
									"redirect_uri": provider.RedirectURI,
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
								enabledProviders = append(enabledProviders, providerMap)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Add SSO providers to the response
 | 
				
			||||||
 | 
						publicSettings["app.sso_providers"] = enabledProviders
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return r.SendEnvelope(publicSettings)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -14,6 +14,14 @@ import (
 | 
				
			|||||||
	"github.com/zerodha/fastglue"
 | 
						"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.
 | 
					// handleGetContacts returns a list of contacts from the database.
 | 
				
			||||||
func handleGetContacts(r *fastglue.Request) error {
 | 
					func handleGetContacts(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
@@ -95,9 +103,9 @@ func handleUpdateContact(r *fastglue.Request) error {
 | 
				
			|||||||
	if v, ok := form.Value["phone_number"]; ok && len(v) > 0 {
 | 
						if v, ok := form.Value["phone_number"]; ok && len(v) > 0 {
 | 
				
			||||||
		phoneNumber = string(v[0])
 | 
							phoneNumber = string(v[0])
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	phoneNumberCallingCode := ""
 | 
						phoneNumberCountryCode := ""
 | 
				
			||||||
	if v, ok := form.Value["phone_number_calling_code"]; ok && len(v) > 0 {
 | 
						if v, ok := form.Value["phone_number_country_code"]; ok && len(v) > 0 {
 | 
				
			||||||
		phoneNumberCallingCode = string(v[0])
 | 
							phoneNumberCountryCode = string(v[0])
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	avatarURL := ""
 | 
						avatarURL := ""
 | 
				
			||||||
	if v, ok := form.Value["avatar_url"]; ok && len(v) > 0 {
 | 
						if v, ok := form.Value["avatar_url"]; ok && len(v) > 0 {
 | 
				
			||||||
@@ -108,8 +116,8 @@ func handleUpdateContact(r *fastglue.Request) error {
 | 
				
			|||||||
	if avatarURL == "null" {
 | 
						if avatarURL == "null" {
 | 
				
			||||||
		avatarURL = ""
 | 
							avatarURL = ""
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	if phoneNumberCallingCode == "null" {
 | 
						if phoneNumberCountryCode == "null" {
 | 
				
			||||||
		phoneNumberCallingCode = ""
 | 
							phoneNumberCountryCode = ""
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	if phoneNumber == "null" {
 | 
						if phoneNumber == "null" {
 | 
				
			||||||
		phoneNumber = ""
 | 
							phoneNumber = ""
 | 
				
			||||||
@@ -138,7 +146,7 @@ func handleUpdateContact(r *fastglue.Request) error {
 | 
				
			|||||||
		Email:                  null.StringFrom(email),
 | 
							Email:                  null.StringFrom(email),
 | 
				
			||||||
		AvatarURL:              null.NewString(avatarURL, avatarURL != ""),
 | 
							AvatarURL:              null.NewString(avatarURL, avatarURL != ""),
 | 
				
			||||||
		PhoneNumber:            null.NewString(phoneNumber, phoneNumber != ""),
 | 
							PhoneNumber:            null.NewString(phoneNumber, phoneNumber != ""),
 | 
				
			||||||
		PhoneNumberCallingCode: null.NewString(phoneNumberCallingCode, phoneNumberCallingCode != ""),
 | 
							PhoneNumberCountryCode: null.NewString(phoneNumberCountryCode, phoneNumberCountryCode != ""),
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if err := app.user.UpdateContact(id, contactToUpdate); err != nil {
 | 
						if err := app.user.UpdateContact(id, contactToUpdate); err != nil {
 | 
				
			||||||
@@ -156,11 +164,17 @@ func handleUpdateContact(r *fastglue.Request) error {
 | 
				
			|||||||
	// Upload avatar?
 | 
						// Upload avatar?
 | 
				
			||||||
	files, ok := form.File["files"]
 | 
						files, ok := form.File["files"]
 | 
				
			||||||
	if ok && len(files) > 0 {
 | 
						if ok && len(files) > 0 {
 | 
				
			||||||
		if err := uploadUserAvatar(r, &contact, files); err != nil {
 | 
							if err := uploadUserAvatar(r, contact, files); err != nil {
 | 
				
			||||||
			return sendErrorEnvelope(r, err)
 | 
								return sendErrorEnvelope(r, err)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return r.SendEnvelope(true)
 | 
					
 | 
				
			||||||
 | 
						// Refetch contact and return it
 | 
				
			||||||
 | 
						contact, err = app.user.GetContact(id, "")
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return r.SendEnvelope(contact)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// handleGetContactNotes returns all notes for a contact.
 | 
					// handleGetContactNotes returns all notes for a contact.
 | 
				
			||||||
@@ -185,15 +199,23 @@ func handleCreateContactNote(r *fastglue.Request) error {
 | 
				
			|||||||
		app          = r.Context.(*App)
 | 
							app          = r.Context.(*App)
 | 
				
			||||||
		contactID, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
 | 
							contactID, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
 | 
				
			||||||
		auser        = r.RequestCtx.UserValue("user").(amodels.User)
 | 
							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)
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "note"), nil, envelope.InputError)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	if err := app.user.CreateNote(contactID, auser.ID, note); err != nil {
 | 
						n, err := app.user.CreateNote(contactID, auser.ID, req.Note)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return r.SendEnvelope(true)
 | 
						n, err = app.user.GetNote(n.ID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return r.SendEnvelope(n)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// handleDeleteContactNote deletes a note for a contact.
 | 
					// handleDeleteContactNote deletes a note for a contact.
 | 
				
			||||||
@@ -227,6 +249,8 @@ func handleDeleteContactNote(r *fastglue.Request) error {
 | 
				
			|||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						app.lo.Info("deleting contact note", "note_id", noteID, "contact_id", contactID, "actor_id", auser.ID)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if err := app.user.DeleteNote(noteID, contactID); err != nil {
 | 
						if err := app.user.DeleteNote(noteID, contactID); err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -238,13 +262,27 @@ func handleBlockContact(r *fastglue.Request) error {
 | 
				
			|||||||
	var (
 | 
						var (
 | 
				
			||||||
		app          = r.Context.(*App)
 | 
							app          = r.Context.(*App)
 | 
				
			||||||
		contactID, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
 | 
							contactID, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
 | 
				
			||||||
		enabled      = r.RequestCtx.PostArgs().GetBool("enabled")
 | 
							auser        = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
 | 
							req          = blockContactReq{}
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if contactID <= 0 {
 | 
						if contactID <= 0 {
 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	if err := app.user.ToggleEnabled(contactID, models.UserTypeContact, enabled); err != nil {
 | 
					
 | 
				
			||||||
 | 
						if err := r.Decode(&req, "json"); err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil))
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						app.lo.Info("setting contact block status", "contact_id", contactID, "enabled", req.Enabled, "actor_id", auser.ID)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if err := app.user.ToggleEnabled(contactID, models.UserTypeContact, req.Enabled); err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return r.SendEnvelope(true)
 | 
					
 | 
				
			||||||
 | 
						contact, err := app.user.GetContact(contactID, "")
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return r.SendEnvelope(contact)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,9 +1,7 @@
 | 
				
			|||||||
package main
 | 
					package main
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import (
 | 
					import (
 | 
				
			||||||
	"encoding/json"
 | 
					 | 
				
			||||||
	"strconv"
 | 
						"strconv"
 | 
				
			||||||
	"strings"
 | 
					 | 
				
			||||||
	"time"
 | 
						"time"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	amodels "github.com/abhinavxd/libredesk/internal/auth/models"
 | 
						amodels "github.com/abhinavxd/libredesk/internal/auth/models"
 | 
				
			||||||
@@ -11,13 +9,49 @@ import (
 | 
				
			|||||||
	"github.com/abhinavxd/libredesk/internal/automation/models"
 | 
						"github.com/abhinavxd/libredesk/internal/automation/models"
 | 
				
			||||||
	cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
 | 
						cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
 | 
				
			||||||
	"github.com/abhinavxd/libredesk/internal/envelope"
 | 
						"github.com/abhinavxd/libredesk/internal/envelope"
 | 
				
			||||||
 | 
						medModels "github.com/abhinavxd/libredesk/internal/media/models"
 | 
				
			||||||
	"github.com/abhinavxd/libredesk/internal/stringutil"
 | 
						"github.com/abhinavxd/libredesk/internal/stringutil"
 | 
				
			||||||
	umodels "github.com/abhinavxd/libredesk/internal/user/models"
 | 
						umodels "github.com/abhinavxd/libredesk/internal/user/models"
 | 
				
			||||||
 | 
						wmodels "github.com/abhinavxd/libredesk/internal/webhook/models"
 | 
				
			||||||
	"github.com/valyala/fasthttp"
 | 
						"github.com/valyala/fasthttp"
 | 
				
			||||||
	"github.com/volatiletech/null/v9"
 | 
						"github.com/volatiletech/null/v9"
 | 
				
			||||||
	"github.com/zerodha/fastglue"
 | 
						"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"`
 | 
				
			||||||
 | 
						Initiator       string `json:"initiator"` // "contact" | "agent"
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// handleGetAllConversations retrieves all conversations.
 | 
					// handleGetAllConversations retrieves all conversations.
 | 
				
			||||||
func handleGetAllConversations(r *fastglue.Request) error {
 | 
					func handleGetAllConversations(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
@@ -240,8 +274,8 @@ func handleGetConversation(r *fastglue.Request) error {
 | 
				
			|||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	prev, _ := app.conversation.GetContactConversations(conv.ContactID)
 | 
						prev, _ := app.conversation.GetContactPreviousConversations(conv.ContactID, 10)
 | 
				
			||||||
	conv.PreviousConversations = filterCurrentConv(prev, conv.UUID)
 | 
						conv.PreviousConversations = filterCurrentPreviousConv(prev, conv.UUID)
 | 
				
			||||||
	return r.SendEnvelope(conv)
 | 
						return r.SendEnvelope(conv)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -291,13 +325,15 @@ func handleGetConversationParticipants(r *fastglue.Request) error {
 | 
				
			|||||||
// handleUpdateUserAssignee updates the user assigned to a conversation.
 | 
					// handleUpdateUserAssignee updates the user assigned to a conversation.
 | 
				
			||||||
func handleUpdateUserAssignee(r *fastglue.Request) error {
 | 
					func handleUpdateUserAssignee(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
		app        = r.Context.(*App)
 | 
							app   = r.Context.(*App)
 | 
				
			||||||
		uuid       = r.RequestCtx.UserValue("uuid").(string)
 | 
							uuid  = r.RequestCtx.UserValue("uuid").(string)
 | 
				
			||||||
		auser      = r.RequestCtx.UserValue("user").(amodels.User)
 | 
							auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
		assigneeID = r.RequestCtx.PostArgs().GetUintOrZero("assignee_id")
 | 
							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, "")
 | 
						user, err := app.user.GetAgent(auser.ID, "")
 | 
				
			||||||
@@ -305,17 +341,19 @@ func handleUpdateUserAssignee(r *fastglue.Request) error {
 | 
				
			|||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	_, err = enforceConversationAccess(app, uuid, user)
 | 
						conversation, err := enforceConversationAccess(app, uuid, user)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if err := app.conversation.UpdateConversationUserAssignee(uuid, assigneeID, user); err != nil {
 | 
						// Already assigned?
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
						if conversation.AssignedUserID.Int == req.AssigneeID {
 | 
				
			||||||
 | 
							return r.SendEnvelope(true)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Evaluate automation rules.
 | 
						if err := app.conversation.UpdateConversationUserAssignee(uuid, req.AssigneeID, user); err != nil {
 | 
				
			||||||
	app.automation.EvaluateConversationUpdateRules(uuid, models.EventConversationUserAssigned)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	return r.SendEnvelope(true)
 | 
						return r.SendEnvelope(true)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -326,12 +364,16 @@ func handleUpdateTeamAssignee(r *fastglue.Request) error {
 | 
				
			|||||||
		app   = r.Context.(*App)
 | 
							app   = r.Context.(*App)
 | 
				
			||||||
		uuid  = r.RequestCtx.UserValue("uuid").(string)
 | 
							uuid  = r.RequestCtx.UserValue("uuid").(string)
 | 
				
			||||||
		auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
							auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
 | 
							req   = teamAssigneeChangeReq{}
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
	assigneeID, err := r.RequestCtx.PostArgs().GetUint("assignee_id")
 | 
					
 | 
				
			||||||
	if err != nil {
 | 
						if err := r.Decode(&req, "json"); err != nil {
 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`assignee_id`"), nil, envelope.InputError)
 | 
							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, "")
 | 
						user, err := app.user.GetAgent(auser.ID, "")
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
@@ -342,28 +384,37 @@ func handleUpdateTeamAssignee(r *fastglue.Request) error {
 | 
				
			|||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	_, err = enforceConversationAccess(app, uuid, user)
 | 
						conversation, err := enforceConversationAccess(app, uuid, user)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							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 {
 | 
						if err := app.conversation.UpdateConversationTeamAssignee(uuid, assigneeID, user); err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Evaluate automation rules on team assignment.
 | 
					 | 
				
			||||||
	app.automation.EvaluateConversationUpdateRules(uuid, models.EventConversationTeamAssigned)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	return r.SendEnvelope(true)
 | 
						return r.SendEnvelope(true)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// handleUpdateConversationPriority updates the priority of a conversation.
 | 
					// handleUpdateConversationPriority updates the priority of a conversation.
 | 
				
			||||||
func handleUpdateConversationPriority(r *fastglue.Request) error {
 | 
					func handleUpdateConversationPriority(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
		app      = r.Context.(*App)
 | 
							app   = r.Context.(*App)
 | 
				
			||||||
		uuid     = r.RequestCtx.UserValue("uuid").(string)
 | 
							uuid  = r.RequestCtx.UserValue("uuid").(string)
 | 
				
			||||||
		auser    = r.RequestCtx.UserValue("user").(amodels.User)
 | 
							auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
		priority = string(r.RequestCtx.PostArgs().Peek("priority"))
 | 
							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 == "" {
 | 
						if priority == "" {
 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`priority`"), nil, envelope.InputError)
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`priority`"), nil, envelope.InputError)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -380,22 +431,26 @@ func handleUpdateConversationPriority(r *fastglue.Request) error {
 | 
				
			|||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Evaluate automation rules.
 | 
					 | 
				
			||||||
	app.automation.EvaluateConversationUpdateRules(uuid, models.EventConversationPriorityChange)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	return r.SendEnvelope(true)
 | 
						return r.SendEnvelope(true)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// handleUpdateConversationStatus updates the status of a conversation.
 | 
					// handleUpdateConversationStatus updates the status of a conversation.
 | 
				
			||||||
func handleUpdateConversationStatus(r *fastglue.Request) error {
 | 
					func handleUpdateConversationStatus(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
		app          = r.Context.(*App)
 | 
							app   = r.Context.(*App)
 | 
				
			||||||
		status       = string(r.RequestCtx.PostArgs().Peek("status"))
 | 
							uuid  = r.RequestCtx.UserValue("uuid").(string)
 | 
				
			||||||
		snoozedUntil = string(r.RequestCtx.PostArgs().Peek("snoozed_until"))
 | 
							auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
		uuid         = r.RequestCtx.UserValue("uuid").(string)
 | 
							req   = statusUpdateReq{}
 | 
				
			||||||
		auser        = r.RequestCtx.UserValue("user").(amodels.User)
 | 
					 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						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
 | 
						// Validate inputs
 | 
				
			||||||
	if status == "" {
 | 
						if status == "" {
 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`status`"), nil, envelope.InputError)
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`status`"), nil, envelope.InputError)
 | 
				
			||||||
@@ -430,9 +485,6 @@ func handleUpdateConversationStatus(r *fastglue.Request) error {
 | 
				
			|||||||
		return sendErrorEnvelope(r, err)
 | 
							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 is `Resolved`, send CSAT survey if enabled on inbox.
 | 
				
			||||||
	if status == cmodels.StatusResolved {
 | 
						if status == cmodels.StatusResolved {
 | 
				
			||||||
		// Check if CSAT is enabled on the inbox and send CSAT survey message.
 | 
							// Check if CSAT is enabled on the inbox and send CSAT survey message.
 | 
				
			||||||
@@ -452,18 +504,19 @@ func handleUpdateConversationStatus(r *fastglue.Request) error {
 | 
				
			|||||||
// handleUpdateConversationtags updates conversation tags.
 | 
					// handleUpdateConversationtags updates conversation tags.
 | 
				
			||||||
func handleUpdateConversationtags(r *fastglue.Request) error {
 | 
					func handleUpdateConversationtags(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
		app      = r.Context.(*App)
 | 
							app   = r.Context.(*App)
 | 
				
			||||||
		tagNames = []string{}
 | 
							auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
		tagJSON  = r.RequestCtx.PostArgs().Peek("tags")
 | 
							uuid  = r.RequestCtx.UserValue("uuid").(string)
 | 
				
			||||||
		auser    = r.RequestCtx.UserValue("user").(amodels.User)
 | 
							req   = tagsUpdateReq{}
 | 
				
			||||||
		uuid     = r.RequestCtx.UserValue("uuid").(string)
 | 
					 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if err := json.Unmarshal(tagJSON, &tagNames); err != nil {
 | 
						if err := r.Decode(&req, "json"); err != nil {
 | 
				
			||||||
		app.lo.Error("error unmarshalling tags JSON", "error", err)
 | 
							app.lo.Error("error decoding tags update request", "error", err)
 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
 | 
							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, "")
 | 
						user, err := app.user.GetAgent(auser.ID, "")
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
@@ -534,33 +587,11 @@ func handleUpdateContactCustomAttributes(r *fastglue.Request) error {
 | 
				
			|||||||
	if err := app.user.UpdateCustomAttributes(conversation.ContactID, attributes); err != nil {
 | 
						if err := app.user.UpdateCustomAttributes(conversation.ContactID, attributes); err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
						// Broadcast update.
 | 
				
			||||||
 | 
						app.conversation.BroadcastConversationUpdate(conversation.UUID, "contact.custom_attributes", attributes)
 | 
				
			||||||
	return r.SendEnvelope(true)
 | 
						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.
 | 
					// 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) {
 | 
					func enforceConversationAccess(app *App, uuid string, user umodels.User) (*cmodels.Conversation, error) {
 | 
				
			||||||
	conversation, err := app.conversation.GetConversation(0, uuid)
 | 
						conversation, err := app.conversation.GetConversation(0, uuid)
 | 
				
			||||||
@@ -592,7 +623,7 @@ func handleRemoveUserAssignee(r *fastglue.Request) error {
 | 
				
			|||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							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 sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return r.SendEnvelope(true)
 | 
						return r.SendEnvelope(true)
 | 
				
			||||||
@@ -613,117 +644,155 @@ func handleRemoveTeamAssignee(r *fastglue.Request) error {
 | 
				
			|||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							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 sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return r.SendEnvelope(true)
 | 
						return r.SendEnvelope(true)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// filterCurrentConv removes the current conversation from the list of conversations.
 | 
					// filterCurrentPreviousConv removes the current conversation from the list of previous conversations.
 | 
				
			||||||
func filterCurrentConv(convs []cmodels.Conversation, uuid string) []cmodels.Conversation {
 | 
					func filterCurrentPreviousConv(convs []cmodels.PreviousConversation, uuid string) []cmodels.PreviousConversation {
 | 
				
			||||||
	for i, c := range convs {
 | 
						for i, c := range convs {
 | 
				
			||||||
		if c.UUID == uuid {
 | 
							if c.UUID == uuid {
 | 
				
			||||||
			return append(convs[:i], convs[i+1:]...)
 | 
								return append(convs[:i], convs[i+1:]...)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return []cmodels.Conversation{}
 | 
						return []cmodels.PreviousConversation{}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// handleCreateConversation creates a new conversation and sends a message to it.
 | 
					// handleCreateConversation creates a new conversation and sends a message to it.
 | 
				
			||||||
func handleCreateConversation(r *fastglue.Request) error {
 | 
					func handleCreateConversation(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
		app             = r.Context.(*App)
 | 
							app   = r.Context.(*App)
 | 
				
			||||||
		auser           = r.RequestCtx.UserValue("user").(amodels.User)
 | 
							auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
		inboxID         = r.RequestCtx.PostArgs().GetUintOrZero("inbox_id")
 | 
							req   = createConversationRequest{}
 | 
				
			||||||
		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}
 | 
					 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Validate required fields
 | 
						if err := r.Decode(&req, "json"); err != nil {
 | 
				
			||||||
	if inboxID <= 0 {
 | 
							app.lo.Error("error decoding create conversation request", "error", err)
 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.fieldRequired", "name", "`inbox_id`"), nil, envelope.InputError)
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
	if subject == "" {
 | 
					 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.fieldRequired", "name", "`subject`"), nil, envelope.InputError)
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
	if content == "" {
 | 
					 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.fieldRequired", "name", "`content`"), nil, envelope.InputError)
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
	if email == "" {
 | 
					 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.fieldRequired", "name", "`contact_email`"), 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) {
 | 
					 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`contact_email`"), nil, envelope.InputError)
 | 
					 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Validate the request
 | 
				
			||||||
 | 
						if err := validateCreateConversationRequest(req, app); err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						to := []string{req.Email}
 | 
				
			||||||
	user, err := app.user.GetAgent(auser.ID, "")
 | 
						user, err := app.user.GetAgent(auser.ID, "")
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Check if inbox exists and is enabled.
 | 
					 | 
				
			||||||
	inbox, err := app.inbox.GetDBRecord(inboxID)
 | 
					 | 
				
			||||||
	if err != nil {
 | 
					 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
	if !inbox.Enabled {
 | 
					 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.disabled", "name", "inbox"), nil, envelope.InputError)
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	// Find or create contact.
 | 
						// Find or create contact.
 | 
				
			||||||
	contact := umodels.User{
 | 
						contact := umodels.User{
 | 
				
			||||||
		Email:           null.StringFrom(email),
 | 
							Email:           null.StringFrom(req.Email),
 | 
				
			||||||
		SourceChannelID: null.StringFrom(email),
 | 
							SourceChannelID: null.StringFrom(req.Email),
 | 
				
			||||||
		FirstName:       firstName,
 | 
							FirstName:       req.FirstName,
 | 
				
			||||||
		LastName:        lastName,
 | 
							LastName:        req.LastName,
 | 
				
			||||||
		InboxID:         inboxID,
 | 
							InboxID:         req.InboxID,
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	if err := app.user.CreateContact(&contact); err != nil {
 | 
						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))
 | 
							return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.contact}"), nil))
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Create conversation
 | 
						// Create conversation first.
 | 
				
			||||||
	conversationID, conversationUUID, err := app.conversation.CreateConversation(
 | 
						conversationID, conversationUUID, err := app.conversation.CreateConversation(
 | 
				
			||||||
		contact.ID,
 | 
							contact.ID,
 | 
				
			||||||
		contact.ContactChannelID,
 | 
							contact.ContactChannelID,
 | 
				
			||||||
		inboxID,
 | 
							req.InboxID,
 | 
				
			||||||
		"",         /** last_message **/
 | 
							"",         /** last_message **/
 | 
				
			||||||
		time.Now(), /** last_message_at **/
 | 
							time.Now(), /** last_message_at **/
 | 
				
			||||||
		subject,
 | 
							req.Subject,
 | 
				
			||||||
		true, /** append reference number to subject **/
 | 
							true, /** append reference number to subject? **/
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		app.lo.Error("error creating conversation", "error", err)
 | 
							app.lo.Error("error creating conversation", "error", err)
 | 
				
			||||||
		return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.conversation}"), nil))
 | 
							return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.conversation}"), nil))
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Send reply to the created conversation.
 | 
						// Get media for the attachment ids.
 | 
				
			||||||
	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 {
 | 
						var media = make([]medModels.Media, 0, len(req.Attachments))
 | 
				
			||||||
		// Delete the conversation if reply fails.
 | 
						for _, id := range req.Attachments {
 | 
				
			||||||
		if err := app.conversation.DeleteConversation(conversationUUID); err != nil {
 | 
							m, err := app.media.Get(id, "")
 | 
				
			||||||
			app.lo.Error("error deleting conversation", "error", err)
 | 
							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)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorSending", "name", "{globals.terms.message}"), nil))
 | 
							media = append(media, m)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Send initial message based on the initiator of conversation.
 | 
				
			||||||
 | 
						switch req.Initiator {
 | 
				
			||||||
 | 
						case umodels.UserTypeAgent:
 | 
				
			||||||
 | 
							// Queue reply.
 | 
				
			||||||
 | 
							if _, err := app.conversation.QueueReply(media, req.InboxID, auser.ID /**sender_id**/, conversationUUID, req.Content, to, nil /**cc**/, nil /**bcc**/, map[string]any{} /**meta**/); err != nil {
 | 
				
			||||||
 | 
								// Delete the conversation if msg queue fails.
 | 
				
			||||||
 | 
								if err := app.conversation.DeleteConversation(conversationUUID); err != nil {
 | 
				
			||||||
 | 
									app.lo.Error("error deleting conversation", "error", err)
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
								return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorSending", "name", "{globals.terms.message}"), nil))
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						case umodels.UserTypeContact:
 | 
				
			||||||
 | 
							// Create contact message.
 | 
				
			||||||
 | 
							if _, err := app.conversation.CreateContactMessage(media, contact.ID, conversationUUID, req.Content, cmodels.ContentTypeHTML); err != nil {
 | 
				
			||||||
 | 
								// Delete the conversation if message creation fails.
 | 
				
			||||||
 | 
								if err := app.conversation.DeleteConversation(conversationUUID); err != nil {
 | 
				
			||||||
 | 
									app.lo.Error("error deleting conversation", "error", err)
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
								return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorSending", "name", "{globals.terms.message}"), nil))
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						default:
 | 
				
			||||||
 | 
							// Guard anyway.
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`initiator`"), nil, envelope.InputError)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Assign the conversation to the agent or team.
 | 
						// Assign the conversation to the agent or team.
 | 
				
			||||||
	if assignedAgentID > 0 {
 | 
						if req.AssignedAgentID > 0 {
 | 
				
			||||||
		app.conversation.UpdateConversationUserAssignee(conversationUUID, assignedAgentID, user)
 | 
							app.conversation.UpdateConversationUserAssignee(conversationUUID, req.AssignedAgentID, user)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	if assignedTeamID > 0 {
 | 
						if req.AssignedTeamID > 0 {
 | 
				
			||||||
		app.conversation.UpdateConversationTeamAssignee(conversationUUID, assignedTeamID, user)
 | 
							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)
 | 
						return r.SendEnvelope(conversation)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// validateCreateConversationRequest validates the create conversation request fields.
 | 
				
			||||||
 | 
					func validateCreateConversationRequest(req createConversationRequest, app *App) error {
 | 
				
			||||||
 | 
						if req.InboxID <= 0 {
 | 
				
			||||||
 | 
							return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`inbox_id`"), nil)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if req.Content == "" {
 | 
				
			||||||
 | 
							return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`content`"), nil)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if req.Email == "" {
 | 
				
			||||||
 | 
							return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`contact_email`"), nil)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if req.FirstName == "" {
 | 
				
			||||||
 | 
							return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`first_name`"), nil)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if !stringutil.ValidEmail(req.Email) {
 | 
				
			||||||
 | 
							return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`contact_email`"), nil)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if req.Initiator != umodels.UserTypeContact && req.Initiator != umodels.UserTypeAgent {
 | 
				
			||||||
 | 
							return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`initiator`"), nil)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Check if inbox exists and is enabled.
 | 
				
			||||||
 | 
						inbox, err := app.inbox.GetDBRecord(req.InboxID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if !inbox.Enabled {
 | 
				
			||||||
 | 
							return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.disabled", "name", "inbox"), nil)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										29
									
								
								cmd/csat.go
									
									
									
									
									
								
							
							
						
						
									
										29
									
								
								cmd/csat.go
									
									
									
									
									
								
							@@ -6,6 +6,10 @@ import (
 | 
				
			|||||||
	"github.com/zerodha/fastglue"
 | 
						"github.com/zerodha/fastglue"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const (
 | 
				
			||||||
 | 
						maxCsatFeedbackLength = 1000
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// handleShowCSAT renders the CSAT page for a given csat.
 | 
					// handleShowCSAT renders the CSAT page for a given csat.
 | 
				
			||||||
func handleShowCSAT(r *fastglue.Request) error {
 | 
					func handleShowCSAT(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
@@ -17,7 +21,7 @@ func handleShowCSAT(r *fastglue.Request) error {
 | 
				
			|||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
 | 
							return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
 | 
				
			||||||
			"Data": map[string]interface{}{
 | 
								"Data": map[string]interface{}{
 | 
				
			||||||
				"ErrorMessage": "Page not found",
 | 
									"ErrorMessage": app.i18n.T("globals.messages.pageNotFound"),
 | 
				
			||||||
			},
 | 
								},
 | 
				
			||||||
		})
 | 
							})
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -25,8 +29,8 @@ func handleShowCSAT(r *fastglue.Request) error {
 | 
				
			|||||||
	if csat.ResponseTimestamp.Valid {
 | 
						if csat.ResponseTimestamp.Valid {
 | 
				
			||||||
		return app.tmpl.RenderWebPage(r.RequestCtx, "info", map[string]interface{}{
 | 
							return app.tmpl.RenderWebPage(r.RequestCtx, "info", map[string]interface{}{
 | 
				
			||||||
			"Data": map[string]interface{}{
 | 
								"Data": map[string]interface{}{
 | 
				
			||||||
				"Title":   "Thank you!",
 | 
									"Title":   app.i18n.T("globals.messages.thankYou"),
 | 
				
			||||||
				"Message": "We appreciate you taking the time to submit your feedback.",
 | 
									"Message": app.i18n.T("csat.thankYouMessage"),
 | 
				
			||||||
			},
 | 
								},
 | 
				
			||||||
		})
 | 
							})
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -35,14 +39,14 @@ func handleShowCSAT(r *fastglue.Request) error {
 | 
				
			|||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
 | 
							return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
 | 
				
			||||||
			"Data": map[string]interface{}{
 | 
								"Data": map[string]interface{}{
 | 
				
			||||||
				"ErrorMessage": "Page not found",
 | 
									"ErrorMessage": app.i18n.T("globals.messages.pageNotFound"),
 | 
				
			||||||
			},
 | 
								},
 | 
				
			||||||
		})
 | 
							})
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	return app.tmpl.RenderWebPage(r.RequestCtx, "csat", map[string]interface{}{
 | 
						return app.tmpl.RenderWebPage(r.RequestCtx, "csat", map[string]interface{}{
 | 
				
			||||||
		"Data": map[string]interface{}{
 | 
							"Data": map[string]interface{}{
 | 
				
			||||||
			"Title":    "Rate your interaction with us",
 | 
								"Title": app.i18n.T("csat.pageTitle"),
 | 
				
			||||||
			"CSAT": map[string]interface{}{
 | 
								"CSAT": map[string]interface{}{
 | 
				
			||||||
				"UUID": csat.UUID,
 | 
									"UUID": csat.UUID,
 | 
				
			||||||
			},
 | 
								},
 | 
				
			||||||
@@ -67,7 +71,7 @@ func handleUpdateCSATResponse(r *fastglue.Request) error {
 | 
				
			|||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
 | 
							return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
 | 
				
			||||||
			"Data": map[string]interface{}{
 | 
								"Data": map[string]interface{}{
 | 
				
			||||||
				"ErrorMessage": "Invalid `rating`",
 | 
									"ErrorMessage": app.i18n.T("globals.messages.somethingWentWrong"),
 | 
				
			||||||
			},
 | 
								},
 | 
				
			||||||
		})
 | 
							})
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -75,7 +79,7 @@ func handleUpdateCSATResponse(r *fastglue.Request) error {
 | 
				
			|||||||
	if ratingI < 1 || ratingI > 5 {
 | 
						if ratingI < 1 || ratingI > 5 {
 | 
				
			||||||
		return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
 | 
							return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
 | 
				
			||||||
			"Data": map[string]interface{}{
 | 
								"Data": map[string]interface{}{
 | 
				
			||||||
				"ErrorMessage": "Invalid `rating`",
 | 
									"ErrorMessage": app.i18n.T("globals.messages.somethingWentWrong"),
 | 
				
			||||||
			},
 | 
								},
 | 
				
			||||||
		})
 | 
							})
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -83,11 +87,16 @@ func handleUpdateCSATResponse(r *fastglue.Request) error {
 | 
				
			|||||||
	if uuid == "" {
 | 
						if uuid == "" {
 | 
				
			||||||
		return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
 | 
							return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
 | 
				
			||||||
			"Data": map[string]interface{}{
 | 
								"Data": map[string]interface{}{
 | 
				
			||||||
				"ErrorMessage": "Invalid `uuid`",
 | 
									"ErrorMessage": app.i18n.T("globals.messages.somethingWentWrong"),
 | 
				
			||||||
			},
 | 
								},
 | 
				
			||||||
		})
 | 
							})
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Trim feedback if it exceeds max length
 | 
				
			||||||
 | 
						if len(feedback) > maxCsatFeedbackLength {
 | 
				
			||||||
 | 
							feedback = feedback[:maxCsatFeedbackLength]
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if err := app.csat.UpdateResponse(uuid, ratingI, feedback); err != nil {
 | 
						if err := app.csat.UpdateResponse(uuid, ratingI, feedback); err != nil {
 | 
				
			||||||
		return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
 | 
							return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
 | 
				
			||||||
			"Data": map[string]interface{}{
 | 
								"Data": map[string]interface{}{
 | 
				
			||||||
@@ -98,8 +107,8 @@ func handleUpdateCSATResponse(r *fastglue.Request) error {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	return app.tmpl.RenderWebPage(r.RequestCtx, "info", map[string]interface{}{
 | 
						return app.tmpl.RenderWebPage(r.RequestCtx, "info", map[string]interface{}{
 | 
				
			||||||
		"Data": map[string]interface{}{
 | 
							"Data": map[string]interface{}{
 | 
				
			||||||
			"Title":   "Thank you!",
 | 
								"Title":   app.i18n.T("globals.messages.thankYou"),
 | 
				
			||||||
			"Message": "We appreciate you taking the time to submit your feedback.",
 | 
								"Message": app.i18n.T("csat.thankYouMessage"),
 | 
				
			||||||
		},
 | 
							},
 | 
				
			||||||
	})
 | 
						})
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -70,10 +70,11 @@ func handleCreateCustomAttribute(r *fastglue.Request) error {
 | 
				
			|||||||
	if err := validateCustomAttribute(app, attribute); err != nil {
 | 
						if err := validateCustomAttribute(app, attribute); err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							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 sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return r.SendEnvelope(true)
 | 
						return r.SendEnvelope(createdAttr)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// handleUpdateCustomAttribute updates an existing custom attribute in the database.
 | 
					// 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 {
 | 
						if err := validateCustomAttribute(app, attribute); err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							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 sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return r.SendEnvelope(true)
 | 
						return r.SendEnvelope(updatedAttr)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// handleDeleteCustomAttribute deletes a custom attribute from the database.
 | 
					// handleDeleteCustomAttribute deletes a custom attribute from the database.
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -15,7 +15,7 @@ import (
 | 
				
			|||||||
// initHandlers initializes the HTTP routes and handlers for the application.
 | 
					// initHandlers initializes the HTTP routes and handlers for the application.
 | 
				
			||||||
func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
 | 
					func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
 | 
				
			||||||
	// Authentication.
 | 
						// Authentication.
 | 
				
			||||||
	g.POST("/api/v1/login", handleLogin)
 | 
						g.POST("/api/v1/auth/login", handleLogin)
 | 
				
			||||||
	g.GET("/logout", auth(handleLogout))
 | 
						g.GET("/logout", auth(handleLogout))
 | 
				
			||||||
	g.GET("/api/v1/oidc/{id}/login", handleOIDCLogin)
 | 
						g.GET("/api/v1/oidc/{id}/login", handleOIDCLogin)
 | 
				
			||||||
	g.GET("/api/v1/oidc/{id}/finish", handleOIDCCallback)
 | 
						g.GET("/api/v1/oidc/{id}/finish", handleOIDCCallback)
 | 
				
			||||||
@@ -23,21 +23,22 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
 | 
				
			|||||||
	// i18n.
 | 
						// i18n.
 | 
				
			||||||
	g.GET("/api/v1/lang/{lang}", handleGetI18nLang)
 | 
						g.GET("/api/v1/lang/{lang}", handleGetI18nLang)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Public config for app initialization.
 | 
				
			||||||
 | 
						g.GET("/api/v1/config", handleGetConfig)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Media.
 | 
						// Media.
 | 
				
			||||||
	g.GET("/uploads/{uuid}", auth(handleServeMedia))
 | 
						g.GET("/uploads/{uuid}", auth(handleServeMedia))
 | 
				
			||||||
	g.POST("/api/v1/media", auth(handleMediaUpload))
 | 
						g.POST("/api/v1/media", auth(handleMediaUpload))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Settings.
 | 
						// Settings.
 | 
				
			||||||
	g.GET("/api/v1/settings/general", handleGetGeneralSettings)
 | 
						g.GET("/api/v1/settings/general", auth(handleGetGeneralSettings))
 | 
				
			||||||
	g.PUT("/api/v1/settings/general", perm(handleUpdateGeneralSettings, "general_settings:manage"))
 | 
						g.PUT("/api/v1/settings/general", perm(handleUpdateGeneralSettings, "general_settings:manage"))
 | 
				
			||||||
	g.GET("/api/v1/settings/notifications/email", perm(handleGetEmailNotificationSettings, "notification_settings:manage"))
 | 
						g.GET("/api/v1/settings/notifications/email", perm(handleGetEmailNotificationSettings, "notification_settings:manage"))
 | 
				
			||||||
	g.PUT("/api/v1/settings/notifications/email", perm(handleUpdateEmailNotificationSettings, "notification_settings:manage"))
 | 
						g.PUT("/api/v1/settings/notifications/email", perm(handleUpdateEmailNotificationSettings, "notification_settings:manage"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// OpenID connect single sign-on.
 | 
						// OpenID connect single sign-on.
 | 
				
			||||||
	g.GET("/api/v1/oidc/enabled", handleGetAllEnabledOIDC)
 | 
					 | 
				
			||||||
	g.GET("/api/v1/oidc", perm(handleGetAllOIDC, "oidc:manage"))
 | 
						g.GET("/api/v1/oidc", perm(handleGetAllOIDC, "oidc:manage"))
 | 
				
			||||||
	g.POST("/api/v1/oidc", perm(handleCreateOIDC, "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.GET("/api/v1/oidc/{id}", perm(handleGetOIDC, "oidc:manage"))
 | 
				
			||||||
	g.PUT("/api/v1/oidc/{id}", perm(handleUpdateOIDC, "oidc:manage"))
 | 
						g.PUT("/api/v1/oidc/{id}", perm(handleUpdateOIDC, "oidc:manage"))
 | 
				
			||||||
	g.DELETE("/api/v1/oidc/{id}", perm(handleDeleteOIDC, "oidc:manage"))
 | 
						g.DELETE("/api/v1/oidc/{id}", perm(handleDeleteOIDC, "oidc:manage"))
 | 
				
			||||||
@@ -111,6 +112,8 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
 | 
				
			|||||||
	g.POST("/api/v1/agents", perm(handleCreateAgent, "users:manage"))
 | 
						g.POST("/api/v1/agents", perm(handleCreateAgent, "users:manage"))
 | 
				
			||||||
	g.PUT("/api/v1/agents/{id}", perm(handleUpdateAgent, "users:manage"))
 | 
						g.PUT("/api/v1/agents/{id}", perm(handleUpdateAgent, "users:manage"))
 | 
				
			||||||
	g.DELETE("/api/v1/agents/{id}", perm(handleDeleteAgent, "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/reset-password", tryAuth(handleResetPassword))
 | 
				
			||||||
	g.POST("/api/v1/agents/set-password", tryAuth(handleSetPassword))
 | 
						g.POST("/api/v1/agents/set-password", tryAuth(handleSetPassword))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -152,15 +155,25 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
 | 
				
			|||||||
	g.DELETE("/api/v1/inboxes/{id}", perm(handleDeleteInbox, "inboxes:manage"))
 | 
						g.DELETE("/api/v1/inboxes/{id}", perm(handleDeleteInbox, "inboxes:manage"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Roles.
 | 
						// Roles.
 | 
				
			||||||
	g.GET("/api/v1/roles", perm(handleGetRoles, "roles:manage"))
 | 
						g.GET("/api/v1/roles", auth(handleGetRoles))
 | 
				
			||||||
	g.GET("/api/v1/roles/{id}", perm(handleGetRole, "roles:manage"))
 | 
						g.GET("/api/v1/roles/{id}", perm(handleGetRole, "roles:manage"))
 | 
				
			||||||
	g.POST("/api/v1/roles", perm(handleCreateRole, "roles:manage"))
 | 
						g.POST("/api/v1/roles", perm(handleCreateRole, "roles:manage"))
 | 
				
			||||||
	g.PUT("/api/v1/roles/{id}", perm(handleUpdateRole, "roles:manage"))
 | 
						g.PUT("/api/v1/roles/{id}", perm(handleUpdateRole, "roles:manage"))
 | 
				
			||||||
	g.DELETE("/api/v1/roles/{id}", perm(handleDeleteRole, "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.
 | 
						// Reports.
 | 
				
			||||||
	g.GET("/api/v1/reports/overview/counts", perm(handleDashboardCounts, "reports:manage"))
 | 
						g.GET("/api/v1/reports/overview/sla", perm(handleOverviewSLA, "reports:manage"))
 | 
				
			||||||
	g.GET("/api/v1/reports/overview/charts", perm(handleDashboardCharts, "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.
 | 
						// Templates.
 | 
				
			||||||
	g.GET("/api/v1/templates", perm(handleGetTemplates, "templates:manage"))
 | 
						g.GET("/api/v1/templates", perm(handleGetTemplates, "templates:manage"))
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -17,6 +17,12 @@ func handleGetInboxes(r *fastglue.Request) error {
 | 
				
			|||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
						for i := range inboxes {
 | 
				
			||||||
 | 
							if err := inboxes[i].ClearPasswords(); err != nil {
 | 
				
			||||||
 | 
								app.lo.Error("error clearing inbox passwords from response", "error", err)
 | 
				
			||||||
 | 
								return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.inbox}"), nil)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
	return r.SendEnvelope(inboxes)
 | 
						return r.SendEnvelope(inboxes)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -47,11 +53,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)
 | 
							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)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if err := validateInbox(app, inbox); err != nil {
 | 
						if err := validateInbox(app, createdInbox); err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -59,7 +66,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.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
 | 
					// handleUpdateInbox updates an inbox
 | 
				
			||||||
@@ -82,7 +95,7 @@ func handleUpdateInbox(r *fastglue.Request) error {
 | 
				
			|||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	err = app.inbox.Update(id, inbox)
 | 
						updatedInbox, err := app.inbox.Update(id, inbox)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -91,7 +104,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.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
 | 
					// handleToggleInbox toggles an inbox
 | 
				
			||||||
@@ -105,7 +124,8 @@ func handleToggleInbox(r *fastglue.Request) error {
 | 
				
			|||||||
			app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
 | 
								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
 | 
							return err
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -113,7 +133,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.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
 | 
					// handleDeleteInbox deletes an inbox
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										57
									
								
								cmd/init.go
									
									
									
									
									
								
							
							
						
						
									
										57
									
								
								cmd/init.go
									
									
									
									
									
								
							@@ -35,6 +35,7 @@ import (
 | 
				
			|||||||
	notifier "github.com/abhinavxd/libredesk/internal/notification"
 | 
						notifier "github.com/abhinavxd/libredesk/internal/notification"
 | 
				
			||||||
	emailnotifier "github.com/abhinavxd/libredesk/internal/notification/providers/email"
 | 
						emailnotifier "github.com/abhinavxd/libredesk/internal/notification/providers/email"
 | 
				
			||||||
	"github.com/abhinavxd/libredesk/internal/oidc"
 | 
						"github.com/abhinavxd/libredesk/internal/oidc"
 | 
				
			||||||
 | 
						"github.com/abhinavxd/libredesk/internal/report"
 | 
				
			||||||
	"github.com/abhinavxd/libredesk/internal/role"
 | 
						"github.com/abhinavxd/libredesk/internal/role"
 | 
				
			||||||
	"github.com/abhinavxd/libredesk/internal/search"
 | 
						"github.com/abhinavxd/libredesk/internal/search"
 | 
				
			||||||
	"github.com/abhinavxd/libredesk/internal/setting"
 | 
						"github.com/abhinavxd/libredesk/internal/setting"
 | 
				
			||||||
@@ -44,6 +45,7 @@ import (
 | 
				
			|||||||
	tmpl "github.com/abhinavxd/libredesk/internal/template"
 | 
						tmpl "github.com/abhinavxd/libredesk/internal/template"
 | 
				
			||||||
	"github.com/abhinavxd/libredesk/internal/user"
 | 
						"github.com/abhinavxd/libredesk/internal/user"
 | 
				
			||||||
	"github.com/abhinavxd/libredesk/internal/view"
 | 
						"github.com/abhinavxd/libredesk/internal/view"
 | 
				
			||||||
 | 
						"github.com/abhinavxd/libredesk/internal/webhook"
 | 
				
			||||||
	"github.com/abhinavxd/libredesk/internal/ws"
 | 
						"github.com/abhinavxd/libredesk/internal/ws"
 | 
				
			||||||
	"github.com/jmoiron/sqlx"
 | 
						"github.com/jmoiron/sqlx"
 | 
				
			||||||
	"github.com/knadh/go-i18n"
 | 
						"github.com/knadh/go-i18n"
 | 
				
			||||||
@@ -219,8 +221,9 @@ func initConversations(
 | 
				
			|||||||
	csat *csat.Manager,
 | 
						csat *csat.Manager,
 | 
				
			||||||
	automationEngine *automation.Engine,
 | 
						automationEngine *automation.Engine,
 | 
				
			||||||
	template *tmpl.Manager,
 | 
						template *tmpl.Manager,
 | 
				
			||||||
 | 
						webhook *webhook.Manager,
 | 
				
			||||||
) *conversation.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,
 | 
							DB:                       db,
 | 
				
			||||||
		Lo:                       initLogger("conversation_manager"),
 | 
							Lo:                       initLogger("conversation_manager"),
 | 
				
			||||||
		OutgoingMessageQueueSize: ko.MustInt("message.outgoing_queue_size"),
 | 
							OutgoingMessageQueueSize: ko.MustInt("message.outgoing_queue_size"),
 | 
				
			||||||
@@ -247,11 +250,12 @@ func initTag(db *sqlx.DB, i18n *i18n.I18n) *tag.Manager {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// initViews inits view manager.
 | 
					// initViews inits view manager.
 | 
				
			||||||
func initView(db *sqlx.DB) *view.Manager {
 | 
					func initView(db *sqlx.DB, i18n *i18n.I18n) *view.Manager {
 | 
				
			||||||
	var lo = initLogger("view_manager")
 | 
						var lo = initLogger("view_manager")
 | 
				
			||||||
	m, err := view.New(view.Opts{
 | 
						m, err := view.New(view.Opts{
 | 
				
			||||||
		DB: db,
 | 
							DB:   db,
 | 
				
			||||||
		Lo: lo,
 | 
							Lo:   lo,
 | 
				
			||||||
 | 
							I18n: i18n,
 | 
				
			||||||
	})
 | 
						})
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		log.Fatalf("error initializing view manager: %v", err)
 | 
							log.Fatalf("error initializing view manager: %v", err)
 | 
				
			||||||
@@ -324,7 +328,7 @@ func initWS(user *user.Manager) *ws.Hub {
 | 
				
			|||||||
func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts *constants, i18n *i18n.I18n) *tmpl.Manager {
 | 
					func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts *constants, i18n *i18n.I18n) *tmpl.Manager {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
		lo      = initLogger("template")
 | 
							lo      = initLogger("template")
 | 
				
			||||||
		funcMap = getTmplFuncs(consts)
 | 
							funcMap = getTmplFuncs(consts, i18n)
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
	tpls, err := stuffbin.ParseTemplatesGlob(funcMap, fs, "/static/email-templates/*.html")
 | 
						tpls, err := stuffbin.ParseTemplatesGlob(funcMap, fs, "/static/email-templates/*.html")
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
@@ -342,7 +346,7 @@ func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts *constants, i18n *
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// getTmplFuncs returns the template functions.
 | 
					// getTmplFuncs returns the template functions.
 | 
				
			||||||
func getTmplFuncs(consts *constants) template.FuncMap {
 | 
					func getTmplFuncs(consts *constants, i18n *i18n.I18n) template.FuncMap {
 | 
				
			||||||
	return template.FuncMap{
 | 
						return template.FuncMap{
 | 
				
			||||||
		"RootURL": func() string {
 | 
							"RootURL": func() string {
 | 
				
			||||||
			return consts.AppBaseURL
 | 
								return consts.AppBaseURL
 | 
				
			||||||
@@ -362,6 +366,9 @@ func getTmplFuncs(consts *constants) template.FuncMap {
 | 
				
			|||||||
		"SiteName": func() string {
 | 
							"SiteName": func() string {
 | 
				
			||||||
			return consts.SiteName
 | 
								return consts.SiteName
 | 
				
			||||||
		},
 | 
							},
 | 
				
			||||||
 | 
							"L": func() interface{} {
 | 
				
			||||||
 | 
								return i18n
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -378,7 +385,10 @@ func reloadSettings(app *App) error {
 | 
				
			|||||||
		app.lo.Error("error unmarshalling settings from DB", "error", err)
 | 
							app.lo.Error("error unmarshalling settings from DB", "error", err)
 | 
				
			||||||
		return err
 | 
							return err
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	if err := ko.Load(confmap.Provider(out, "."), nil); err != nil {
 | 
						app.Lock()
 | 
				
			||||||
 | 
						err = ko.Load(confmap.Provider(out, "."), nil)
 | 
				
			||||||
 | 
						app.Unlock()
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
		app.lo.Error("error loading settings into koanf", "error", err)
 | 
							app.lo.Error("error loading settings into koanf", "error", err)
 | 
				
			||||||
		return err
 | 
							return err
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -390,7 +400,7 @@ func reloadSettings(app *App) error {
 | 
				
			|||||||
// reloadTemplates reloads the templates from the filesystem.
 | 
					// reloadTemplates reloads the templates from the filesystem.
 | 
				
			||||||
func reloadTemplates(app *App) error {
 | 
					func reloadTemplates(app *App) error {
 | 
				
			||||||
	app.lo.Info("reloading templates")
 | 
						app.lo.Info("reloading templates")
 | 
				
			||||||
	funcMap := getTmplFuncs(app.consts.Load().(*constants))
 | 
						funcMap := getTmplFuncs(app.consts.Load().(*constants), app.i18n)
 | 
				
			||||||
	tpls, err := stuffbin.ParseTemplatesGlob(funcMap, app.fs, "/static/email-templates/*.html")
 | 
						tpls, err := stuffbin.ParseTemplatesGlob(funcMap, app.fs, "/static/email-templates/*.html")
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		app.lo.Error("error parsing email templates", "error", err)
 | 
							app.lo.Error("error parsing email templates", "error", err)
 | 
				
			||||||
@@ -823,6 +833,37 @@ func initActivityLog(db *sqlx.DB, i18n *i18n.I18n) *activitylog.Manager {
 | 
				
			|||||||
	return m
 | 
						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.
 | 
					// initLogger initializes a logf logger.
 | 
				
			||||||
func initLogger(src string) *logf.Logger {
 | 
					func initLogger(src string) *logf.Logger {
 | 
				
			||||||
	lvl, env := ko.MustString("app.log_level"), ko.MustString("app.env")
 | 
						lvl, env := ko.MustString("app.log_level"), ko.MustString("app.env")
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										26
									
								
								cmd/login.go
									
									
									
									
									
								
							
							
						
						
									
										26
									
								
								cmd/login.go
									
									
									
									
									
								
							@@ -3,23 +3,35 @@ package main
 | 
				
			|||||||
import (
 | 
					import (
 | 
				
			||||||
	amodels "github.com/abhinavxd/libredesk/internal/auth/models"
 | 
						amodels "github.com/abhinavxd/libredesk/internal/auth/models"
 | 
				
			||||||
	"github.com/abhinavxd/libredesk/internal/envelope"
 | 
						"github.com/abhinavxd/libredesk/internal/envelope"
 | 
				
			||||||
	umodels "github.com/abhinavxd/libredesk/internal/user/models"
 | 
					 | 
				
			||||||
	realip "github.com/ferluci/fast-realip"
 | 
						realip "github.com/ferluci/fast-realip"
 | 
				
			||||||
	"github.com/valyala/fasthttp"
 | 
						"github.com/valyala/fasthttp"
 | 
				
			||||||
	"github.com/zerodha/fastglue"
 | 
						"github.com/zerodha/fastglue"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type loginRequest struct {
 | 
				
			||||||
 | 
						Email    string `json:"email"`
 | 
				
			||||||
 | 
						Password string `json:"password"`
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// handleLogin logs in the user and returns the user.
 | 
					// handleLogin logs in the user and returns the user.
 | 
				
			||||||
func handleLogin(r *fastglue.Request) error {
 | 
					func handleLogin(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
		app      = r.Context.(*App)
 | 
							app      = r.Context.(*App)
 | 
				
			||||||
		email    = string(r.RequestCtx.PostArgs().Peek("email"))
 | 
					 | 
				
			||||||
		password = r.RequestCtx.PostArgs().Peek("password")
 | 
					 | 
				
			||||||
		ip       = realip.FromRequest(r.RequestCtx)
 | 
							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.
 | 
						// Verify email and password.
 | 
				
			||||||
	user, err := app.user.VerifyPassword(email, password)
 | 
						user, err := app.user.VerifyPassword(loginReq.Email, []byte(loginReq.Password))
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -29,12 +41,6 @@ func handleLogin(r *fastglue.Request) error {
 | 
				
			|||||||
		return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("user.accountDisabled"), nil))
 | 
							return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("user.accountDisabled"), nil))
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Set user availability status to online.
 | 
					 | 
				
			||||||
	if err := app.user.UpdateAvailability(user.ID, umodels.Online); err != nil {
 | 
					 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
	user.AvailabilityStatus = umodels.Online
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	if err := app.auth.SaveSession(amodels.User{
 | 
						if err := app.auth.SaveSession(amodels.User{
 | 
				
			||||||
		ID:        user.ID,
 | 
							ID:        user.ID,
 | 
				
			||||||
		Email:     user.Email.String,
 | 
							Email:     user.Email.String,
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										15
									
								
								cmd/macro.go
									
									
									
									
									
								
							
							
						
						
									
										15
									
								
								cmd/macro.go
									
									
									
									
									
								
							@@ -81,12 +81,12 @@ func handleCreateMacro(r *fastglue.Request) error {
 | 
				
			|||||||
		return sendErrorEnvelope(r, err)
 | 
							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 {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	return r.SendEnvelope(macro)
 | 
						return r.SendEnvelope(createdMacro)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// handleUpdateMacro updates a macro.
 | 
					// handleUpdateMacro updates a macro.
 | 
				
			||||||
@@ -110,11 +110,12 @@ func handleUpdateMacro(r *fastglue.Request) error {
 | 
				
			|||||||
		return sendErrorEnvelope(r, err)
 | 
							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 sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	return r.SendEnvelope(macro)
 | 
						return r.SendEnvelope(updatedMacro)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// handleDeleteMacro deletes macro.
 | 
					// 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)
 | 
							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
 | 
						var act []autoModels.RuleAction
 | 
				
			||||||
	if err := json.Unmarshal(macro.Actions, &act); err != nil {
 | 
						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)
 | 
							return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.macroAction}"), nil)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	for _, a := range act {
 | 
						for _, a := range act {
 | 
				
			||||||
		if len(a.Value) == 0 {
 | 
							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
 | 
						return nil
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										28
									
								
								cmd/main.go
									
									
									
									
									
								
							
							
						
						
									
										28
									
								
								cmd/main.go
									
									
									
									
									
								
							@@ -23,6 +23,7 @@ import (
 | 
				
			|||||||
	customAttribute "github.com/abhinavxd/libredesk/internal/custom_attribute"
 | 
						customAttribute "github.com/abhinavxd/libredesk/internal/custom_attribute"
 | 
				
			||||||
	"github.com/abhinavxd/libredesk/internal/macro"
 | 
						"github.com/abhinavxd/libredesk/internal/macro"
 | 
				
			||||||
	notifier "github.com/abhinavxd/libredesk/internal/notification"
 | 
						notifier "github.com/abhinavxd/libredesk/internal/notification"
 | 
				
			||||||
 | 
						"github.com/abhinavxd/libredesk/internal/report"
 | 
				
			||||||
	"github.com/abhinavxd/libredesk/internal/search"
 | 
						"github.com/abhinavxd/libredesk/internal/search"
 | 
				
			||||||
	"github.com/abhinavxd/libredesk/internal/sla"
 | 
						"github.com/abhinavxd/libredesk/internal/sla"
 | 
				
			||||||
	"github.com/abhinavxd/libredesk/internal/view"
 | 
						"github.com/abhinavxd/libredesk/internal/view"
 | 
				
			||||||
@@ -40,6 +41,7 @@ import (
 | 
				
			|||||||
	"github.com/abhinavxd/libredesk/internal/team"
 | 
						"github.com/abhinavxd/libredesk/internal/team"
 | 
				
			||||||
	"github.com/abhinavxd/libredesk/internal/template"
 | 
						"github.com/abhinavxd/libredesk/internal/template"
 | 
				
			||||||
	"github.com/abhinavxd/libredesk/internal/user"
 | 
						"github.com/abhinavxd/libredesk/internal/user"
 | 
				
			||||||
 | 
						"github.com/abhinavxd/libredesk/internal/webhook"
 | 
				
			||||||
	"github.com/knadh/go-i18n"
 | 
						"github.com/knadh/go-i18n"
 | 
				
			||||||
	"github.com/knadh/koanf/v2"
 | 
						"github.com/knadh/koanf/v2"
 | 
				
			||||||
	"github.com/knadh/stuffbin"
 | 
						"github.com/knadh/stuffbin"
 | 
				
			||||||
@@ -90,9 +92,13 @@ type App struct {
 | 
				
			|||||||
	activityLog     *activitylog.Manager
 | 
						activityLog     *activitylog.Manager
 | 
				
			||||||
	notifier        *notifier.Service
 | 
						notifier        *notifier.Service
 | 
				
			||||||
	customAttribute *customAttribute.Manager
 | 
						customAttribute *customAttribute.Manager
 | 
				
			||||||
 | 
						report          *report.Manager
 | 
				
			||||||
 | 
						webhook         *webhook.Manager
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Global state that stores data on an available app update.
 | 
						// Global state that stores data on an available app update.
 | 
				
			||||||
	update *AppUpdate
 | 
						update *AppUpdate
 | 
				
			||||||
 | 
						// Flag to indicate if app restart is required for settings to take effect.
 | 
				
			||||||
 | 
						restartRequired bool
 | 
				
			||||||
	sync.Mutex
 | 
						sync.Mutex
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -157,13 +163,23 @@ func main() {
 | 
				
			|||||||
	settings := initSettings(db)
 | 
						settings := initSettings(db)
 | 
				
			||||||
	loadSettings(settings)
 | 
						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 (
 | 
						var (
 | 
				
			||||||
		autoAssignInterval          = ko.MustDuration("autoassigner.autoassign_interval")
 | 
							autoAssignInterval          = ko.MustDuration("autoassigner.autoassign_interval")
 | 
				
			||||||
		unsnoozeInterval            = ko.MustDuration("conversation.unsnooze_interval")
 | 
							unsnoozeInterval            = ko.MustDuration("conversation.unsnooze_interval")
 | 
				
			||||||
		automationWorkers           = ko.MustInt("automation.worker_count")
 | 
							automationWorkers           = ko.MustInt("automation.worker_count")
 | 
				
			||||||
		messageOutgoingQWorkers     = ko.MustDuration("message.outgoing_queue_workers")
 | 
							messageOutgoingQWorkers     = ko.MustDuration("message.outgoing_queue_workers")
 | 
				
			||||||
		messageIncomingQWorkers     = ko.MustDuration("message.incoming_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")
 | 
							slaEvaluationInterval       = ko.MustDuration("sla.evaluation_interval")
 | 
				
			||||||
		lo                          = initLogger(appName)
 | 
							lo                          = initLogger(appName)
 | 
				
			||||||
		rdb                         = initRedis()
 | 
							rdb                         = initRedis()
 | 
				
			||||||
@@ -179,12 +195,13 @@ func main() {
 | 
				
			|||||||
		inbox                       = initInbox(db, i18n)
 | 
							inbox                       = initInbox(db, i18n)
 | 
				
			||||||
		team                        = initTeam(db, i18n)
 | 
							team                        = initTeam(db, i18n)
 | 
				
			||||||
		businessHours               = initBusinessHours(db, i18n)
 | 
							businessHours               = initBusinessHours(db, i18n)
 | 
				
			||||||
 | 
							webhook                     = initWebhook(db, i18n)
 | 
				
			||||||
		user                        = initUser(i18n, db)
 | 
							user                        = initUser(i18n, db)
 | 
				
			||||||
		wsHub                       = initWS(user)
 | 
							wsHub                       = initWS(user)
 | 
				
			||||||
		notifier                    = initNotifier()
 | 
							notifier                    = initNotifier()
 | 
				
			||||||
		automation                  = initAutomationEngine(db, i18n)
 | 
							automation                  = initAutomationEngine(db, i18n)
 | 
				
			||||||
		sla                         = initSLA(db, team, settings, businessHours, notifier, template, user, 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)
 | 
							autoassigner                = initAutoAssigner(team, user, conversation)
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
	automation.SetConversationStore(conversation)
 | 
						automation.SetConversationStore(conversation)
 | 
				
			||||||
@@ -194,6 +211,7 @@ func main() {
 | 
				
			|||||||
	go autoassigner.Run(ctx, autoAssignInterval)
 | 
						go autoassigner.Run(ctx, autoAssignInterval)
 | 
				
			||||||
	go conversation.Run(ctx, messageIncomingQWorkers, messageOutgoingQWorkers, messageOutgoingScanInterval)
 | 
						go conversation.Run(ctx, messageIncomingQWorkers, messageOutgoingQWorkers, messageOutgoingScanInterval)
 | 
				
			||||||
	go conversation.RunUnsnoozer(ctx, unsnoozeInterval)
 | 
						go conversation.RunUnsnoozer(ctx, unsnoozeInterval)
 | 
				
			||||||
 | 
						go webhook.Run(ctx)
 | 
				
			||||||
	go notifier.Run(ctx)
 | 
						go notifier.Run(ctx)
 | 
				
			||||||
	go sla.Run(ctx, slaEvaluationInterval)
 | 
						go sla.Run(ctx, slaEvaluationInterval)
 | 
				
			||||||
	go sla.SendNotifications(ctx)
 | 
						go sla.SendNotifications(ctx)
 | 
				
			||||||
@@ -223,13 +241,15 @@ func main() {
 | 
				
			|||||||
		activityLog:     initActivityLog(db, i18n),
 | 
							activityLog:     initActivityLog(db, i18n),
 | 
				
			||||||
		customAttribute: initCustomAttribute(db, i18n),
 | 
							customAttribute: initCustomAttribute(db, i18n),
 | 
				
			||||||
		authz:           initAuthz(i18n),
 | 
							authz:           initAuthz(i18n),
 | 
				
			||||||
		view:            initView(db),
 | 
							view:            initView(db, i18n),
 | 
				
			||||||
 | 
							report:          initReport(db, i18n),
 | 
				
			||||||
		csat:            initCSAT(db, i18n),
 | 
							csat:            initCSAT(db, i18n),
 | 
				
			||||||
		search:          initSearch(db, i18n),
 | 
							search:          initSearch(db, i18n),
 | 
				
			||||||
		role:            initRole(db, i18n),
 | 
							role:            initRole(db, i18n),
 | 
				
			||||||
		tag:             initTag(db, i18n),
 | 
							tag:             initTag(db, i18n),
 | 
				
			||||||
		macro:           initMacro(db, i18n),
 | 
							macro:           initMacro(db, i18n),
 | 
				
			||||||
		ai:              initAI(db, i18n),
 | 
							ai:              initAI(db, i18n),
 | 
				
			||||||
 | 
							webhook:         webhook,
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	app.consts.Store(constants)
 | 
						app.consts.Store(constants)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -273,6 +293,8 @@ func main() {
 | 
				
			|||||||
	autoassigner.Close()
 | 
						autoassigner.Close()
 | 
				
			||||||
	colorlog.Red("Shutting down notifier...")
 | 
						colorlog.Red("Shutting down notifier...")
 | 
				
			||||||
	notifier.Close()
 | 
						notifier.Close()
 | 
				
			||||||
 | 
						colorlog.Red("Shutting down webhook...")
 | 
				
			||||||
 | 
						webhook.Close()
 | 
				
			||||||
	colorlog.Red("Shutting down conversation...")
 | 
						colorlog.Red("Shutting down conversation...")
 | 
				
			||||||
	conversation.Close()
 | 
						conversation.Close()
 | 
				
			||||||
	colorlog.Red("Shutting down SLA...")
 | 
						colorlog.Red("Shutting down SLA...")
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										12
									
								
								cmd/media.go
									
									
									
									
									
								
							
							
						
						
									
										12
									
								
								cmd/media.go
									
									
									
									
									
								
							@@ -185,6 +185,18 @@ func handleServeMedia(r *fastglue.Request) error {
 | 
				
			|||||||
	consts := app.consts.Load().(*constants)
 | 
						consts := app.consts.Load().(*constants)
 | 
				
			||||||
	switch consts.UploadProvider {
 | 
						switch consts.UploadProvider {
 | 
				
			||||||
	case "fs":
 | 
						case "fs":
 | 
				
			||||||
 | 
							disposition := "attachment"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							// Keep certain content types inline.
 | 
				
			||||||
 | 
							if strings.HasPrefix(media.ContentType, "image/") ||
 | 
				
			||||||
 | 
								strings.HasPrefix(media.ContentType, "video/") ||
 | 
				
			||||||
 | 
								media.ContentType == "application/pdf" {
 | 
				
			||||||
 | 
								disposition = "inline"
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							r.RequestCtx.Response.Header.Set("Content-Type", media.ContentType)
 | 
				
			||||||
 | 
							r.RequestCtx.Response.Header.Set("Content-Disposition", fmt.Sprintf(`%s; filename="%s"`, disposition, media.Filename))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		fasthttp.ServeFile(r.RequestCtx, filepath.Join(ko.String("upload.fs.upload_path"), uuid))
 | 
							fasthttp.ServeFile(r.RequestCtx, filepath.Join(ko.String("upload.fs.upload_path"), uuid))
 | 
				
			||||||
	case "s3":
 | 
						case "s3":
 | 
				
			||||||
		r.RequestCtx.Redirect(app.media.GetURL(uuid), http.StatusFound)
 | 
							r.RequestCtx.Redirect(app.media.GetURL(uuid), http.StatusFound)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2,11 +2,14 @@ package main
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
import (
 | 
					import (
 | 
				
			||||||
	"strconv"
 | 
						"strconv"
 | 
				
			||||||
 | 
						"strings"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	amodels "github.com/abhinavxd/libredesk/internal/auth/models"
 | 
						amodels "github.com/abhinavxd/libredesk/internal/auth/models"
 | 
				
			||||||
	"github.com/abhinavxd/libredesk/internal/automation/models"
 | 
						authzModels "github.com/abhinavxd/libredesk/internal/authz/models"
 | 
				
			||||||
 | 
						cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
 | 
				
			||||||
	"github.com/abhinavxd/libredesk/internal/envelope"
 | 
						"github.com/abhinavxd/libredesk/internal/envelope"
 | 
				
			||||||
	medModels "github.com/abhinavxd/libredesk/internal/media/models"
 | 
						medModels "github.com/abhinavxd/libredesk/internal/media/models"
 | 
				
			||||||
 | 
						umodels "github.com/abhinavxd/libredesk/internal/user/models"
 | 
				
			||||||
	"github.com/valyala/fasthttp"
 | 
						"github.com/valyala/fasthttp"
 | 
				
			||||||
	"github.com/zerodha/fastglue"
 | 
						"github.com/zerodha/fastglue"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
@@ -18,6 +21,7 @@ type messageReq struct {
 | 
				
			|||||||
	To          []string `json:"to"`
 | 
						To          []string `json:"to"`
 | 
				
			||||||
	CC          []string `json:"cc"`
 | 
						CC          []string `json:"cc"`
 | 
				
			||||||
	BCC         []string `json:"bcc"`
 | 
						BCC         []string `json:"bcc"`
 | 
				
			||||||
 | 
						SenderType  string   `json:"sender_type"`
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// handleGetMessages returns messages for a conversation.
 | 
					// handleGetMessages returns messages for a conversation.
 | 
				
			||||||
@@ -100,7 +104,7 @@ func handleGetMessage(r *fastglue.Request) error {
 | 
				
			|||||||
	return r.SendEnvelope(message)
 | 
						return r.SendEnvelope(message)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// handleRetryMessage changes message status so it can be retried for sending.
 | 
					// handleRetryMessage changes message status to `pending`, so it's enqueued for sending.
 | 
				
			||||||
func handleRetryMessage(r *fastglue.Request) error {
 | 
					func handleRetryMessage(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
		app   = r.Context.(*App)
 | 
							app   = r.Context.(*App)
 | 
				
			||||||
@@ -132,7 +136,6 @@ func handleSendMessage(r *fastglue.Request) error {
 | 
				
			|||||||
		app   = r.Context.(*App)
 | 
							app   = r.Context.(*App)
 | 
				
			||||||
		auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
							auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
		cuuid = r.RequestCtx.UserValue("cuuid").(string)
 | 
							cuuid = r.RequestCtx.UserValue("cuuid").(string)
 | 
				
			||||||
		media = []medModels.Media{}
 | 
					 | 
				
			||||||
		req   = messageReq{}
 | 
							req   = messageReq{}
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -152,7 +155,32 @@ func handleSendMessage(r *fastglue.Request) error {
 | 
				
			|||||||
		return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
 | 
							return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Prepare attachments.
 | 
						if req.SenderType != umodels.UserTypeAgent && req.SenderType != umodels.UserTypeContact {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`sender_type`"), nil, envelope.InputError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Contacts cannot send private messages
 | 
				
			||||||
 | 
						if req.SenderType == umodels.UserTypeContact && req.Private {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.badRequest"), nil, envelope.InputError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Check if user has permission to send messages as contact
 | 
				
			||||||
 | 
						if req.SenderType == umodels.UserTypeContact {
 | 
				
			||||||
 | 
							parts := strings.Split(authzModels.PermMessagesWriteAsContact, ":")
 | 
				
			||||||
 | 
							if len(parts) != 2 {
 | 
				
			||||||
 | 
								return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorChecking", "name", "{globals.terms.permission}"), nil))
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							ok, err := app.authz.Enforce(user, parts[0], parts[1])
 | 
				
			||||||
 | 
							if err != nil {
 | 
				
			||||||
 | 
								return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorChecking", "name", "{globals.terms.permission}"), nil))
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							if !ok {
 | 
				
			||||||
 | 
								return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.PermissionError)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Get media for all attachments.
 | 
				
			||||||
 | 
						var media = make([]medModels.Media, 0, len(req.Attachments))
 | 
				
			||||||
	for _, id := range req.Attachments {
 | 
						for _, id := range req.Attachments {
 | 
				
			||||||
		m, err := app.media.Get(id, "")
 | 
							m, err := app.media.Get(id, "")
 | 
				
			||||||
		if err != nil {
 | 
							if err != nil {
 | 
				
			||||||
@@ -162,16 +190,28 @@ func handleSendMessage(r *fastglue.Request) error {
 | 
				
			|||||||
		media = append(media, m)
 | 
							media = append(media, m)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if req.Private {
 | 
						// Create contact message.
 | 
				
			||||||
		if err := app.conversation.SendPrivateNote(media, user.ID, cuuid, req.Message); err != nil {
 | 
						if req.SenderType == umodels.UserTypeContact {
 | 
				
			||||||
 | 
							message, err := app.conversation.CreateContactMessage(media, int(conv.ContactID), cuuid, req.Message, cmodels.ContentTypeHTML)
 | 
				
			||||||
 | 
							if err != nil {
 | 
				
			||||||
			return sendErrorEnvelope(r, err)
 | 
								return sendErrorEnvelope(r, err)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	} else {
 | 
							return r.SendEnvelope(message)
 | 
				
			||||||
		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)
 | 
					
 | 
				
			||||||
 | 
						// Send private note.
 | 
				
			||||||
 | 
						if req.Private {
 | 
				
			||||||
 | 
							message, err := app.conversation.SendPrivateNote(media, user.ID, cuuid, req.Message)
 | 
				
			||||||
 | 
							if err != nil {
 | 
				
			||||||
 | 
								return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							return r.SendEnvelope(message)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Queue reply.
 | 
				
			||||||
 | 
						message, err := app.conversation.QueueReply(media, conv.InboxID, user.ID, cuuid, req.Message, req.To, req.CC, req.BCC, map[string]any{} /**meta**/)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return r.SendEnvelope(message)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -6,30 +6,80 @@ import (
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	amodels "github.com/abhinavxd/libredesk/internal/auth/models"
 | 
						amodels "github.com/abhinavxd/libredesk/internal/auth/models"
 | 
				
			||||||
	"github.com/abhinavxd/libredesk/internal/envelope"
 | 
						"github.com/abhinavxd/libredesk/internal/envelope"
 | 
				
			||||||
 | 
						"github.com/abhinavxd/libredesk/internal/user/models"
 | 
				
			||||||
	"github.com/valyala/fasthttp"
 | 
						"github.com/valyala/fasthttp"
 | 
				
			||||||
	"github.com/zerodha/fastglue"
 | 
						"github.com/zerodha/fastglue"
 | 
				
			||||||
	"github.com/zerodha/simplesessions/v3"
 | 
						"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.
 | 
					// 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.
 | 
					// 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 {
 | 
					func tryAuth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
 | 
				
			||||||
	return func(r *fastglue.Request) error {
 | 
						return func(r *fastglue.Request) error {
 | 
				
			||||||
		app := r.Context.(*App)
 | 
							app := r.Context.(*App)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		// Try to validate session without returning error.
 | 
							// Try to authenticate user using shared authentication logic, but don't return errors
 | 
				
			||||||
		userSession, err := app.auth.ValidateSession(r)
 | 
							user, err := authenticateUser(r, app)
 | 
				
			||||||
		if err != nil || userSession.ID <= 0 {
 | 
					 | 
				
			||||||
			return handler(r)
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		// Try to get user.
 | 
					 | 
				
			||||||
		user, err := app.user.GetAgent(userSession.ID, "")
 | 
					 | 
				
			||||||
		if err != nil {
 | 
							if err != nil {
 | 
				
			||||||
 | 
								// Authentication failed, but this is optional, so continue without user
 | 
				
			||||||
			return handler(r)
 | 
								return handler(r)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		// Set user in context if found.
 | 
							// Set user in context if authentication succeeded.
 | 
				
			||||||
		r.RequestCtx.SetUserValue("user", amodels.User{
 | 
							r.RequestCtx.SetUserValue("user", amodels.User{
 | 
				
			||||||
			ID:        user.ID,
 | 
								ID:        user.ID,
 | 
				
			||||||
			Email:     user.Email.String,
 | 
								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 {
 | 
					func auth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
 | 
				
			||||||
	return func(r *fastglue.Request) error {
 | 
						return func(r *fastglue.Request) error {
 | 
				
			||||||
		var app = r.Context.(*App)
 | 
							var app = r.Context.(*App)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		// Validate session and fetch user.
 | 
							// Authenticate user using shared authentication logic
 | 
				
			||||||
		userSession, err := app.auth.ValidateSession(r)
 | 
							user, err := authenticateUser(r, app)
 | 
				
			||||||
		if err != nil || userSession.ID <= 0 {
 | 
							if err != nil {
 | 
				
			||||||
			app.lo.Error("error validating session", "error", err)
 | 
								if envErr, ok := err.(envelope.Error); ok {
 | 
				
			||||||
			return r.SendErrorEnvelope(http.StatusUnauthorized, app.i18n.T("auth.invalidOrExpiredSession"), nil, envelope.GeneralError)
 | 
									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.
 | 
							// 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{
 | 
							r.RequestCtx.SetUserValue("user", amodels.User{
 | 
				
			||||||
			ID:        user.ID,
 | 
								ID:        user.ID,
 | 
				
			||||||
			Email:     user.Email.String,
 | 
								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.
 | 
					// perm checks if the user has the required permission to access the endpoint.
 | 
				
			||||||
// and sets the user in the request context.
 | 
					// Supports both API key authentication (Authorization header) and session-based authentication.
 | 
				
			||||||
func perm(handler fastglue.FastRequestHandler, perm string) fastglue.FastRequestHandler {
 | 
					func perm(handler fastglue.FastRequestHandler, perm string) fastglue.FastRequestHandler {
 | 
				
			||||||
	return func(r *fastglue.Request) error {
 | 
						return func(r *fastglue.Request) error {
 | 
				
			||||||
		var (
 | 
							var app = r.Context.(*App)
 | 
				
			||||||
			app         = r.Context.(*App)
 | 
					 | 
				
			||||||
			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.
 | 
							// Authenticate user using shared authentication logic
 | 
				
			||||||
		if cookieToken == "" || hdrToken == "" || cookieToken != hdrToken {
 | 
							user, err := authenticateUser(r, app)
 | 
				
			||||||
			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, "")
 | 
					 | 
				
			||||||
		if err != nil {
 | 
							if err != nil {
 | 
				
			||||||
			return sendErrorEnvelope(r, err)
 | 
								if envErr, ok := err.(envelope.Error); ok {
 | 
				
			||||||
		}
 | 
									if envErr.ErrorType == envelope.PermissionError {
 | 
				
			||||||
 | 
										return r.SendErrorEnvelope(http.StatusForbidden, envErr.Message, nil, envelope.PermissionError)
 | 
				
			||||||
		// Destroy session if user is disabled.
 | 
									}
 | 
				
			||||||
		if !user.Enabled {
 | 
									return r.SendErrorEnvelope(http.StatusUnauthorized, envErr.Message, nil, envelope.GeneralError)
 | 
				
			||||||
			if err := app.auth.DestroySession(r); err != nil {
 | 
					 | 
				
			||||||
				app.lo.Error("error destroying session", "error", err)
 | 
					 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
			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.
 | 
							// Split the permission string into object and action and enforce it.
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										50
									
								
								cmd/oidc.go
									
									
									
									
									
								
							
							
						
						
									
										50
									
								
								cmd/oidc.go
									
									
									
									
									
								
							@@ -11,16 +11,6 @@ import (
 | 
				
			|||||||
	"github.com/zerodha/fastglue"
 | 
						"github.com/zerodha/fastglue"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// handleGetAllEnabledOIDC returns all enabled OIDC records
 | 
					 | 
				
			||||||
func handleGetAllEnabledOIDC(r *fastglue.Request) error {
 | 
					 | 
				
			||||||
	app := r.Context.(*App)
 | 
					 | 
				
			||||||
	out, err := app.oidc.GetAllEnabled()
 | 
					 | 
				
			||||||
	if err != nil {
 | 
					 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
	return r.SendEnvelope(out)
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// handleGetAllOIDC returns all OIDC records
 | 
					// handleGetAllOIDC returns all OIDC records
 | 
				
			||||||
func handleGetAllOIDC(r *fastglue.Request) error {
 | 
					func handleGetAllOIDC(r *fastglue.Request) error {
 | 
				
			||||||
	app := r.Context.(*App)
 | 
						app := r.Context.(*App)
 | 
				
			||||||
@@ -50,18 +40,6 @@ func handleGetOIDC(r *fastglue.Request) error {
 | 
				
			|||||||
	return r.SendEnvelope(o)
 | 
						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.
 | 
					// handleCreateOIDC creates a new OIDC record.
 | 
				
			||||||
func handleCreateOIDC(r *fastglue.Request) error {
 | 
					func handleCreateOIDC(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
@@ -72,7 +50,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)
 | 
							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)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -80,7 +64,11 @@ func handleCreateOIDC(r *fastglue.Request) error {
 | 
				
			|||||||
	if err := reloadAuth(app); err != nil {
 | 
						if err := reloadAuth(app); err != nil {
 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.couldNotReload", "name", "OIDC"), nil, envelope.GeneralError)
 | 
							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.
 | 
					// handleUpdateOIDC updates an OIDC record.
 | 
				
			||||||
@@ -98,7 +86,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)
 | 
							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)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -106,7 +100,11 @@ func handleUpdateOIDC(r *fastglue.Request) error {
 | 
				
			|||||||
	if err := reloadAuth(app); err != nil {
 | 
						if err := reloadAuth(app); err != nil {
 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.couldNotReload", "name", "OIDC"), nil, envelope.GeneralError)
 | 
							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.
 | 
					// handleDeleteOIDC deletes an OIDC record.
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										45
									
								
								cmd/report.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								cmd/report.go
									
									
									
									
									
										Normal 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)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										10
									
								
								cmd/roles.go
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								cmd/roles.go
									
									
									
									
									
								
							@@ -55,10 +55,11 @@ func handleCreateRole(r *fastglue.Request) error {
 | 
				
			|||||||
	if err := r.Decode(&req, "json"); err != nil {
 | 
						if err := r.Decode(&req, "json"); err != nil {
 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
 | 
							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 sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return r.SendEnvelope(true)
 | 
						return r.SendEnvelope(createdRole)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// handleUpdateRole updates a role
 | 
					// handleUpdateRole updates a role
 | 
				
			||||||
@@ -71,8 +72,9 @@ func handleUpdateRole(r *fastglue.Request) error {
 | 
				
			|||||||
	if err := r.Decode(&req, "json"); err != nil {
 | 
						if err := r.Decode(&req, "json"); err != nil {
 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
 | 
							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 sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return r.SendEnvelope(true)
 | 
						return r.SendEnvelope(updatedRole)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -31,6 +31,8 @@ func handleGetGeneralSettings(r *fastglue.Request) error {
 | 
				
			|||||||
	settings["app.update"] = app.update
 | 
						settings["app.update"] = app.update
 | 
				
			||||||
	// Set app version.
 | 
						// Set app version.
 | 
				
			||||||
	settings["app.version"] = versionString
 | 
						settings["app.version"] = versionString
 | 
				
			||||||
 | 
						// Set restart required flag.
 | 
				
			||||||
 | 
						settings["app.restart_required"] = app.restartRequired
 | 
				
			||||||
	return r.SendEnvelope(settings)
 | 
						return r.SendEnvelope(settings)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -45,6 +47,11 @@ func handleUpdateGeneralSettings(r *fastglue.Request) error {
 | 
				
			|||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.badRequest"), nil, envelope.InputError)
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.badRequest"), nil, envelope.InputError)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Get current language before update.
 | 
				
			||||||
 | 
						app.Lock()
 | 
				
			||||||
 | 
						oldLang := ko.String("app.lang")
 | 
				
			||||||
 | 
						app.Unlock()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Remove any trailing slash `/` from the root url.
 | 
						// Remove any trailing slash `/` from the root url.
 | 
				
			||||||
	req.RootURL = strings.TrimRight(req.RootURL, "/")
 | 
						req.RootURL = strings.TrimRight(req.RootURL, "/")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -55,6 +62,17 @@ func handleUpdateGeneralSettings(r *fastglue.Request) error {
 | 
				
			|||||||
	if err := reloadSettings(app); err != nil {
 | 
						if err := reloadSettings(app); err != nil {
 | 
				
			||||||
		return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.couldNotReload", "name", app.i18n.T("globals.terms.setting")), nil)
 | 
							return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.couldNotReload", "name", app.i18n.T("globals.terms.setting")), nil)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Check if language changed and reload i18n if needed.
 | 
				
			||||||
 | 
						app.Lock()
 | 
				
			||||||
 | 
						newLang := ko.String("app.lang")
 | 
				
			||||||
 | 
						if oldLang != newLang {
 | 
				
			||||||
 | 
							app.lo.Info("language changed, reloading i18n", "old_lang", oldLang, "new_lang", newLang)
 | 
				
			||||||
 | 
							app.i18n = initI18n(app.fs)
 | 
				
			||||||
 | 
							app.lo.Info("reloaded i18n", "old_lang", oldLang, "new_lang", newLang)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						app.Unlock()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if err := reloadTemplates(app); err != nil {
 | 
						if err := reloadTemplates(app); err != nil {
 | 
				
			||||||
		return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.couldNotReload", "name", app.i18n.T("globals.terms.setting")), nil)
 | 
							return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.couldNotReload", "name", app.i18n.T("globals.terms.setting")), nil)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -109,6 +127,7 @@ func handleUpdateEmailNotificationSettings(r *fastglue.Request) error {
 | 
				
			|||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.invalidFromAddress"), nil, envelope.InputError)
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.invalidFromAddress"), nil, envelope.InputError)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// If empty then retain previous password.
 | 
				
			||||||
	if req.Password == "" {
 | 
						if req.Password == "" {
 | 
				
			||||||
		req.Password = cur.Password
 | 
							req.Password = cur.Password
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -117,6 +136,10 @@ func handleUpdateEmailNotificationSettings(r *fastglue.Request) error {
 | 
				
			|||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// No reload implemented, so user has to restart the app.
 | 
						// Email notification settings require app restart to take effect.
 | 
				
			||||||
 | 
						app.Lock()
 | 
				
			||||||
 | 
						app.restartRequired = true
 | 
				
			||||||
 | 
						app.Unlock()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	return r.SendEnvelope(true)
 | 
						return r.SendEnvelope(true)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										10
									
								
								cmd/sla.go
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								cmd/sla.go
									
									
									
									
									
								
							@@ -54,11 +54,12 @@ func handleCreateSLA(r *fastglue.Request) error {
 | 
				
			|||||||
		return sendErrorEnvelope(r, err)
 | 
							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 sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	return r.SendEnvelope("SLA created successfully.")
 | 
						return r.SendEnvelope(createdSLA)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// handleUpdateSLA updates the SLA with the given ID.
 | 
					// handleUpdateSLA updates the SLA with the given ID.
 | 
				
			||||||
@@ -81,11 +82,12 @@ func handleUpdateSLA(r *fastglue.Request) error {
 | 
				
			|||||||
		return sendErrorEnvelope(r, err)
 | 
							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 sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	return r.SendEnvelope(true)
 | 
						return r.SendEnvelope(updatedSLA)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// handleDeleteSLA deletes the SLA with the given ID.
 | 
					// handleDeleteSLA deletes the SLA with the given ID.
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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)
 | 
							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 {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	return r.SendEnvelope(true)
 | 
						return r.SendEnvelope(createdStatus)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func handleDeleteStatus(r *fastglue.Request) error {
 | 
					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)
 | 
							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 {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	return r.SendEnvelope(true)
 | 
						return r.SendEnvelope(updatedStatus)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										10
									
								
								cmd/tags.go
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								cmd/tags.go
									
									
									
									
									
								
							@@ -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)
 | 
							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 sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	return r.SendEnvelope(true)
 | 
						return r.SendEnvelope(createdTag)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// handleDeleteTag deletes a tag from the database.
 | 
					// 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)
 | 
							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 sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	return r.SendEnvelope(true)
 | 
						return r.SendEnvelope(updatedTag)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										47
									
								
								cmd/teams.go
									
									
									
									
									
								
							
							
						
						
									
										47
									
								
								cmd/teams.go
									
									
									
									
									
								
							@@ -4,8 +4,8 @@ import (
 | 
				
			|||||||
	"strconv"
 | 
						"strconv"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	"github.com/abhinavxd/libredesk/internal/envelope"
 | 
						"github.com/abhinavxd/libredesk/internal/envelope"
 | 
				
			||||||
 | 
						"github.com/abhinavxd/libredesk/internal/team/models"
 | 
				
			||||||
	"github.com/valyala/fasthttp"
 | 
						"github.com/valyala/fasthttp"
 | 
				
			||||||
	"github.com/volatiletech/null/v9"
 | 
					 | 
				
			||||||
	"github.com/zerodha/fastglue"
 | 
						"github.com/zerodha/fastglue"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -52,41 +52,42 @@ func handleGetTeam(r *fastglue.Request) error {
 | 
				
			|||||||
// handleCreateTeam creates a new team.
 | 
					// handleCreateTeam creates a new team.
 | 
				
			||||||
func handleCreateTeam(r *fastglue.Request) error {
 | 
					func handleCreateTeam(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
		app                             = r.Context.(*App)
 | 
							app = r.Context.(*App)
 | 
				
			||||||
		name                            = string(r.RequestCtx.PostArgs().Peek("name"))
 | 
							req = models.Team{}
 | 
				
			||||||
		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")))
 | 
					 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
	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 sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return r.SendEnvelope(true)
 | 
						return r.SendEnvelope(createdTeam)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// handleUpdateTeam updates an existing team.
 | 
					// handleUpdateTeam updates an existing team.
 | 
				
			||||||
func handleUpdateTeam(r *fastglue.Request) error {
 | 
					func handleUpdateTeam(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
		app                             = r.Context.(*App)
 | 
							app   = r.Context.(*App)
 | 
				
			||||||
		name                            = string(r.RequestCtx.PostArgs().Peek("name"))
 | 
							id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
 | 
				
			||||||
		timezone                        = string(r.RequestCtx.PostArgs().Peek("timezone"))
 | 
							req   = models.Team{}
 | 
				
			||||||
		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")))
 | 
					 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if id < 1 {
 | 
						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 sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return r.SendEnvelope(true)
 | 
						return r.SendEnvelope(updatedTeam)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// handleDeleteTeam deletes a team
 | 
					// handleDeleteTeam deletes a team
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -53,10 +53,11 @@ func handleCreateTemplate(r *fastglue.Request) error {
 | 
				
			|||||||
	if req.Name == "" {
 | 
						if req.Name == "" {
 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
 | 
							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 sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return r.SendEnvelope(true)
 | 
						return r.SendEnvelope(template)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// handleUpdateTemplate updates a template.
 | 
					// handleUpdateTemplate updates a template.
 | 
				
			||||||
@@ -76,10 +77,11 @@ func handleUpdateTemplate(r *fastglue.Request) error {
 | 
				
			|||||||
	if req.Name == "" {
 | 
						if req.Name == "" {
 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
 | 
							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 sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return r.SendEnvelope(true)
 | 
						return r.SendEnvelope(updatedTemplate)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// handleDeleteTemplate deletes a template.
 | 
					// handleDeleteTemplate deletes a template.
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -34,6 +34,8 @@ var migList = []migFunc{
 | 
				
			|||||||
	{"v0.4.0", migrations.V0_4_0},
 | 
						{"v0.4.0", migrations.V0_4_0},
 | 
				
			||||||
	{"v0.5.0", migrations.V0_5_0},
 | 
						{"v0.5.0", migrations.V0_5_0},
 | 
				
			||||||
	{"v0.6.0", migrations.V0_6_0},
 | 
						{"v0.6.0", migrations.V0_6_0},
 | 
				
			||||||
 | 
						{"v0.7.0", migrations.V0_7_0},
 | 
				
			||||||
 | 
						{"v0.7.4", migrations.V0_7_4},
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// upgrade upgrades the database to the current version by running SQL migration files
 | 
					// upgrade upgrades the database to the current version by running SQL migration files
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										319
									
								
								cmd/users.go
									
									
									
									
									
								
							
							
						
						
									
										319
									
								
								cmd/users.go
									
									
									
									
									
								
							@@ -26,11 +26,38 @@ const (
 | 
				
			|||||||
	maxAvatarSizeMB = 2
 | 
						maxAvatarSizeMB = 2
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type updateAvailabilityRequest struct {
 | 
				
			||||||
 | 
						Status string `json:"status"`
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type resetPasswordRequest struct {
 | 
				
			||||||
 | 
						Email string `json:"email"`
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type setPasswordRequest struct {
 | 
				
			||||||
 | 
						Token    string `json:"token"`
 | 
				
			||||||
 | 
						Password string `json:"password"`
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type availabilityRequest struct {
 | 
				
			||||||
 | 
						Status string `json:"status"`
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type agentReq struct {
 | 
				
			||||||
 | 
						FirstName          string   `json:"first_name"`
 | 
				
			||||||
 | 
						LastName           string   `json:"last_name"`
 | 
				
			||||||
 | 
						Email              string   `json:"email"`
 | 
				
			||||||
 | 
						SendWelcomeEmail   bool     `json:"send_welcome_email"`
 | 
				
			||||||
 | 
						Teams              []string `json:"teams"`
 | 
				
			||||||
 | 
						Roles              []string `json:"roles"`
 | 
				
			||||||
 | 
						Enabled            bool     `json:"enabled"`
 | 
				
			||||||
 | 
						AvailabilityStatus string   `json:"availability_status"`
 | 
				
			||||||
 | 
						NewPassword        string   `json:"new_password,omitempty"`
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// handleGetAgents returns all agents.
 | 
					// handleGetAgents returns all agents.
 | 
				
			||||||
func handleGetAgents(r *fastglue.Request) error {
 | 
					func handleGetAgents(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var app = r.Context.(*App)
 | 
				
			||||||
		app = r.Context.(*App)
 | 
					 | 
				
			||||||
	)
 | 
					 | 
				
			||||||
	agents, err := app.user.GetAgents()
 | 
						agents, err := app.user.GetAgents()
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
@@ -50,9 +77,7 @@ func handleGetAgentsCompact(r *fastglue.Request) error {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
// handleGetAgent returns an agent.
 | 
					// handleGetAgent returns an agent.
 | 
				
			||||||
func handleGetAgent(r *fastglue.Request) error {
 | 
					func handleGetAgent(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var app = r.Context.(*App)
 | 
				
			||||||
		app = r.Context.(*App)
 | 
					 | 
				
			||||||
	)
 | 
					 | 
				
			||||||
	id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
 | 
						id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
 | 
				
			||||||
	if err != nil || id <= 0 {
 | 
						if err != nil || id <= 0 {
 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
 | 
				
			||||||
@@ -67,48 +92,56 @@ func handleGetAgent(r *fastglue.Request) error {
 | 
				
			|||||||
// handleUpdateAgentAvailability updates the current agent availability.
 | 
					// handleUpdateAgentAvailability updates the current agent availability.
 | 
				
			||||||
func handleUpdateAgentAvailability(r *fastglue.Request) error {
 | 
					func handleUpdateAgentAvailability(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
		app    = r.Context.(*App)
 | 
							app      = r.Context.(*App)
 | 
				
			||||||
		auser  = r.RequestCtx.UserValue("user").(amodels.User)
 | 
							auser    = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
		status = string(r.RequestCtx.PostArgs().Peek("status"))
 | 
							ip       = realip.FromRequest(r.RequestCtx)
 | 
				
			||||||
		ip     = realip.FromRequest(r.RequestCtx)
 | 
							availReq availabilityRequest
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Decode JSON request
 | 
				
			||||||
 | 
						if err := r.Decode(&availReq, "json"); err != nil {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Fetch entire agent
 | 
				
			||||||
	agent, err := app.user.GetAgent(auser.ID, "")
 | 
						agent, err := app.user.GetAgent(auser.ID, "")
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Same status?
 | 
						// Same status?
 | 
				
			||||||
	if agent.AvailabilityStatus == status {
 | 
						if agent.AvailabilityStatus == availReq.Status {
 | 
				
			||||||
		return r.SendEnvelope(true)
 | 
							return r.SendEnvelope(agent)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Update availability status.
 | 
						// Update availability status
 | 
				
			||||||
	if err := app.user.UpdateAvailability(auser.ID, status); err != nil {
 | 
						if err := app.user.UpdateAvailability(auser.ID, availReq.Status); err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Skip activity log if agent returns online from away (to avoid spam).
 | 
						// Skip activity log if agent returns online from away (to avoid spam).
 | 
				
			||||||
	if !(agent.AvailabilityStatus == models.Away && status == models.Online) {
 | 
						if !(agent.AvailabilityStatus == models.Away && availReq.Status == models.Online) {
 | 
				
			||||||
		if err := app.activityLog.UserAvailability(auser.ID, auser.Email, status, ip, "", 0); err != nil {
 | 
							if err := app.activityLog.UserAvailability(auser.ID, auser.Email, availReq.Status, ip, "", 0); err != nil {
 | 
				
			||||||
			app.lo.Error("error creating activity log", "error", err)
 | 
								app.lo.Error("error creating activity log", "error", err)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	return r.SendEnvelope(true)
 | 
						// Fetch updated agent and return
 | 
				
			||||||
 | 
						agent, err = app.user.GetAgent(auser.ID, "")
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return r.SendEnvelope(agent)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// handleGetCurrentAgentTeams returns the teams of an agent.
 | 
					// handleGetCurrentAgentTeams returns the teams of current agent.
 | 
				
			||||||
func handleGetCurrentAgentTeams(r *fastglue.Request) error {
 | 
					func handleGetCurrentAgentTeams(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
		app   = r.Context.(*App)
 | 
							app   = r.Context.(*App)
 | 
				
			||||||
		auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
							auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
	agent, err := app.user.GetAgent(auser.ID, "")
 | 
						teams, err := app.team.GetUserTeams(auser.ID)
 | 
				
			||||||
	if err != nil {
 | 
					 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	teams, err := app.team.GetUserTeams(agent.ID)
 | 
					 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -121,11 +154,6 @@ func handleUpdateCurrentAgent(r *fastglue.Request) error {
 | 
				
			|||||||
		app   = r.Context.(*App)
 | 
							app   = r.Context.(*App)
 | 
				
			||||||
		auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
							auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
	agent, err := app.user.GetAgent(auser.ID, "")
 | 
					 | 
				
			||||||
	if err != nil {
 | 
					 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	form, err := r.RequestCtx.MultipartForm()
 | 
						form, err := r.RequestCtx.MultipartForm()
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		app.lo.Error("error parsing form data", "error", err)
 | 
							app.lo.Error("error parsing form data", "error", err)
 | 
				
			||||||
@@ -136,50 +164,53 @@ func handleUpdateCurrentAgent(r *fastglue.Request) error {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	// Upload avatar?
 | 
						// Upload avatar?
 | 
				
			||||||
	if ok && len(files) > 0 {
 | 
						if ok && len(files) > 0 {
 | 
				
			||||||
		if err := uploadUserAvatar(r, &agent, files); err != nil {
 | 
							agent, err := app.user.GetAgent(auser.ID, "")
 | 
				
			||||||
 | 
							if err != nil {
 | 
				
			||||||
 | 
								return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							if err := uploadUserAvatar(r, agent, files); err != nil {
 | 
				
			||||||
			return sendErrorEnvelope(r, err)
 | 
								return sendErrorEnvelope(r, err)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return r.SendEnvelope(true)
 | 
					
 | 
				
			||||||
 | 
						// Fetch updated agent and return.
 | 
				
			||||||
 | 
						agent, err := app.user.GetAgent(auser.ID, "")
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return r.SendEnvelope(agent)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// handleCreateAgent creates a new agent.
 | 
					// handleCreateAgent creates a new agent.
 | 
				
			||||||
func handleCreateAgent(r *fastglue.Request) error {
 | 
					func handleCreateAgent(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
		app  = r.Context.(*App)
 | 
							app = r.Context.(*App)
 | 
				
			||||||
		user = models.User{}
 | 
							req = agentReq{}
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
	if err := r.Decode(&user, "json"); err != nil {
 | 
					
 | 
				
			||||||
 | 
						if err := r.Decode(&req, "json"); err != nil {
 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if user.Email.String == "" {
 | 
						// Validate agent request
 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`email`"), nil, envelope.InputError)
 | 
						if err := validateAgentRequest(r, &req); err != nil {
 | 
				
			||||||
 | 
							return err
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if user.Roles == nil {
 | 
						agent, err := app.user.CreateAgent(req.FirstName, req.LastName, req.Email, req.Roles)
 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`role`"), nil, envelope.InputError)
 | 
						if err != nil {
 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	if user.FirstName == "" {
 | 
					 | 
				
			||||||
		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)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Upsert user teams.
 | 
						// Upsert user teams.
 | 
				
			||||||
	if len(user.Teams) > 0 {
 | 
						if len(req.Teams) > 0 {
 | 
				
			||||||
		if err := app.team.UpsertUserTeams(user.ID, user.Teams.Names()); err != nil {
 | 
							app.team.UpsertUserTeams(agent.ID, req.Teams)
 | 
				
			||||||
			return sendErrorEnvelope(r, err)
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if user.SendWelcomeEmail {
 | 
						if req.SendWelcomeEmail {
 | 
				
			||||||
		// Generate reset token.
 | 
							// Generate reset token.
 | 
				
			||||||
		resetToken, err := app.user.SetResetPasswordToken(user.ID)
 | 
							resetToken, err := app.user.SetResetPasswordToken(agent.ID)
 | 
				
			||||||
		if err != nil {
 | 
							if err != nil {
 | 
				
			||||||
			return sendErrorEnvelope(r, err)
 | 
								return sendErrorEnvelope(r, err)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
@@ -187,53 +218,51 @@ func handleCreateAgent(r *fastglue.Request) error {
 | 
				
			|||||||
		// Render template and send email.
 | 
							// Render template and send email.
 | 
				
			||||||
		content, err := app.tmpl.RenderInMemoryTemplate(tmpl.TmplWelcome, map[string]any{
 | 
							content, err := app.tmpl.RenderInMemoryTemplate(tmpl.TmplWelcome, map[string]any{
 | 
				
			||||||
			"ResetToken": resetToken,
 | 
								"ResetToken": resetToken,
 | 
				
			||||||
			"Email":      user.Email.String,
 | 
								"Email":      req.Email,
 | 
				
			||||||
		})
 | 
							})
 | 
				
			||||||
		if err != nil {
 | 
							if err != nil {
 | 
				
			||||||
			app.lo.Error("error rendering template", "error", err)
 | 
								app.lo.Error("error rendering template", "error", err)
 | 
				
			||||||
			return r.SendEnvelope(true)
 | 
					 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		if err := app.notifier.Send(notifier.Message{
 | 
							if err := app.notifier.Send(notifier.Message{
 | 
				
			||||||
			RecipientEmails: []string{user.Email.String},
 | 
								RecipientEmails: []string{req.Email},
 | 
				
			||||||
			Subject:         "Welcome to Libredesk",
 | 
								Subject:         app.i18n.T("globals.messages.welcomeToLibredesk"),
 | 
				
			||||||
			Content:         content,
 | 
								Content:         content,
 | 
				
			||||||
			Provider:        notifier.ProviderEmail,
 | 
								Provider:        notifier.ProviderEmail,
 | 
				
			||||||
		}); err != nil {
 | 
							}); err != nil {
 | 
				
			||||||
			app.lo.Error("error sending notification message", "error", err)
 | 
								app.lo.Error("error sending notification message", "error", err)
 | 
				
			||||||
			return r.SendEnvelope(true)
 | 
					 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return r.SendEnvelope(true)
 | 
					
 | 
				
			||||||
 | 
						// Refetch agent as other details might've changed.
 | 
				
			||||||
 | 
						agent, err = app.user.GetAgent(agent.ID, "")
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return r.SendEnvelope(agent)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// handleUpdateAgent updates an agent.
 | 
					// handleUpdateAgent updates an agent.
 | 
				
			||||||
func handleUpdateAgent(r *fastglue.Request) error {
 | 
					func handleUpdateAgent(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
		app   = r.Context.(*App)
 | 
							app   = r.Context.(*App)
 | 
				
			||||||
		user  = models.User{}
 | 
							req   = agentReq{}
 | 
				
			||||||
		auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
							auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
		ip    = realip.FromRequest(r.RequestCtx)
 | 
							ip    = realip.FromRequest(r.RequestCtx)
 | 
				
			||||||
 | 
							id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
	id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
 | 
						if id == 0 {
 | 
				
			||||||
	if err != nil || id == 0 {
 | 
					 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`id`"), nil, envelope.InputError)
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`id`"), nil, envelope.InputError)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if err := r.Decode(&user, "json"); err != nil {
 | 
						if err := r.Decode(&req, "json"); err != nil {
 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if user.Email.String == "" {
 | 
						// Validate agent request
 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`email`"), nil, envelope.InputError)
 | 
						if err := validateAgentRequest(r, &req); err != nil {
 | 
				
			||||||
	}
 | 
							return err
 | 
				
			||||||
 | 
					 | 
				
			||||||
	if user.Roles == nil {
 | 
					 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`role`"), nil, envelope.InputError)
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	if user.FirstName == "" {
 | 
					 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`first_name`"), nil, envelope.InputError)
 | 
					 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	agent, err := app.user.GetAgent(id, "")
 | 
						agent, err := app.user.GetAgent(id, "")
 | 
				
			||||||
@@ -242,24 +271,33 @@ func handleUpdateAgent(r *fastglue.Request) error {
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
	oldAvailabilityStatus := agent.AvailabilityStatus
 | 
						oldAvailabilityStatus := agent.AvailabilityStatus
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Update agent.
 | 
						// Update agent with individual fields
 | 
				
			||||||
	if err = app.user.UpdateAgent(id, user); err != nil {
 | 
						if err = app.user.UpdateAgent(id, req.FirstName, req.LastName, req.Email, req.Roles, req.Enabled, req.AvailabilityStatus, req.NewPassword); err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Invalidate authz cache.
 | 
				
			||||||
 | 
						defer app.authz.InvalidateUserCache(id)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Create activity log if user availability status changed.
 | 
						// Create activity log if user availability status changed.
 | 
				
			||||||
	if oldAvailabilityStatus != user.AvailabilityStatus {
 | 
						if oldAvailabilityStatus != req.AvailabilityStatus {
 | 
				
			||||||
		if err := app.activityLog.UserAvailability(auser.ID, auser.Email, user.AvailabilityStatus, ip, user.Email.String, id); err != nil {
 | 
							if err := app.activityLog.UserAvailability(auser.ID, auser.Email, req.AvailabilityStatus, ip, req.Email, id); err != nil {
 | 
				
			||||||
			app.lo.Error("error creating activity log", "error", err)
 | 
								app.lo.Error("error creating activity log", "error", err)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Upsert agent teams.
 | 
						// Upsert agent teams.
 | 
				
			||||||
	if err := app.team.UpsertUserTeams(id, user.Teams.Names()); err != nil {
 | 
						if err := app.team.UpsertUserTeams(id, req.Teams); err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	return r.SendEnvelope(true)
 | 
						// Refetch agent and return.
 | 
				
			||||||
 | 
						agent, err = app.user.GetAgent(id, "")
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return r.SendEnvelope(agent)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// handleDeleteAgent soft deletes an agent.
 | 
					// handleDeleteAgent soft deletes an agent.
 | 
				
			||||||
@@ -339,22 +377,26 @@ func handleDeleteCurrentAgentAvatar(r *fastglue.Request) error {
 | 
				
			|||||||
func handleResetPassword(r *fastglue.Request) error {
 | 
					func handleResetPassword(r *fastglue.Request) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
		app       = r.Context.(*App)
 | 
							app       = r.Context.(*App)
 | 
				
			||||||
		p         = r.RequestCtx.PostArgs()
 | 
					 | 
				
			||||||
		auser, ok = r.RequestCtx.UserValue("user").(amodels.User)
 | 
							auser, ok = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
		email     = string(p.Peek("email"))
 | 
							resetReq  resetPasswordRequest
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
	if ok && auser.ID > 0 {
 | 
						if ok && auser.ID > 0 {
 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("user.userAlreadyLoggedIn"), nil, envelope.InputError)
 | 
							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)
 | 
							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 {
 | 
						if err != nil {
 | 
				
			||||||
		// Send 200 even if user not found, to prevent email enumeration.
 | 
							// Send 200 even if user not found, to prevent email enumeration.
 | 
				
			||||||
		return r.SendEnvelope("Reset password email sent successfully.")
 | 
							return r.SendEnvelope(true)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	token, err := app.user.SetResetPasswordToken(agent.ID)
 | 
						token, err := app.user.SetResetPasswordToken(agent.ID)
 | 
				
			||||||
@@ -389,20 +431,22 @@ func handleSetPassword(r *fastglue.Request) error {
 | 
				
			|||||||
	var (
 | 
						var (
 | 
				
			||||||
		app       = r.Context.(*App)
 | 
							app       = r.Context.(*App)
 | 
				
			||||||
		agent, ok = r.RequestCtx.UserValue("user").(amodels.User)
 | 
							agent, ok = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
		p         = r.RequestCtx.PostArgs()
 | 
							req       setPasswordRequest
 | 
				
			||||||
		password  = string(p.Peek("password"))
 | 
					 | 
				
			||||||
		token     = string(p.Peek("token"))
 | 
					 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if ok && agent.ID > 0 {
 | 
						if ok && agent.ID > 0 {
 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("user.userAlreadyLoggedIn"), nil, envelope.InputError)
 | 
							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)
 | 
							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)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -410,13 +454,13 @@ func handleSetPassword(r *fastglue.Request) error {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// uploadUserAvatar uploads the user avatar.
 | 
					// uploadUserAvatar uploads the user avatar.
 | 
				
			||||||
func uploadUserAvatar(r *fastglue.Request, user *models.User, files []*multipart.FileHeader) error {
 | 
					func uploadUserAvatar(r *fastglue.Request, user models.User, files []*multipart.FileHeader) error {
 | 
				
			||||||
	var app = r.Context.(*App)
 | 
						var app = r.Context.(*App)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	fileHeader := files[0]
 | 
						fileHeader := files[0]
 | 
				
			||||||
	file, err := fileHeader.Open()
 | 
						file, err := fileHeader.Open()
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		app.lo.Error("error opening uploaded file", "error", err)
 | 
							app.lo.Error("error opening uploaded file", "user_id", user.ID, "error", err)
 | 
				
			||||||
		return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorReading", "name", "{globals.terms.file}"), nil)
 | 
							return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorReading", "name", "{globals.terms.file}"), nil)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	defer file.Close()
 | 
						defer file.Close()
 | 
				
			||||||
@@ -433,7 +477,7 @@ func uploadUserAvatar(r *fastglue.Request, user *models.User, files []*multipart
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	// Check file size
 | 
						// Check file size
 | 
				
			||||||
	if bytesToMegabytes(srcFileSize) > maxAvatarSizeMB {
 | 
						if bytesToMegabytes(srcFileSize) > maxAvatarSizeMB {
 | 
				
			||||||
		app.lo.Error("error uploaded file size is larger than max allowed", "size", bytesToMegabytes(srcFileSize), "max_allowed", maxAvatarSizeMB)
 | 
							app.lo.Error("error uploaded file size is larger than max allowed", "user_id", user.ID, "size", bytesToMegabytes(srcFileSize), "max_allowed", maxAvatarSizeMB)
 | 
				
			||||||
		return envelope.NewError(
 | 
							return envelope.NewError(
 | 
				
			||||||
			envelope.InputError,
 | 
								envelope.InputError,
 | 
				
			||||||
			app.i18n.Ts("media.fileSizeTooLarge", "size", fmt.Sprintf("%dMB", maxAvatarSizeMB)),
 | 
								app.i18n.Ts("media.fileSizeTooLarge", "size", fmt.Sprintf("%dMB", maxAvatarSizeMB)),
 | 
				
			||||||
@@ -450,25 +494,110 @@ func uploadUserAvatar(r *fastglue.Request, user *models.User, files []*multipart
 | 
				
			|||||||
	meta := []byte("{}")
 | 
						meta := []byte("{}")
 | 
				
			||||||
	media, err := app.media.UploadAndInsert(srcFileName, srcContentType, contentID, linkedModel, linkedID, file, int(srcFileSize), disposition, meta)
 | 
						media, err := app.media.UploadAndInsert(srcFileName, srcContentType, contentID, linkedModel, linkedID, file, int(srcFileSize), disposition, meta)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		app.lo.Error("error uploading file", "error", err)
 | 
							app.lo.Error("error uploading file", "user_id", user.ID, "error", err)
 | 
				
			||||||
		return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorUploading", "name", "{globals.terms.file}"), nil)
 | 
							return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorUploading", "name", "{globals.terms.file}"), nil)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Delete current avatar.
 | 
						// Delete current avatar.
 | 
				
			||||||
	if user.AvatarURL.Valid {
 | 
						if user.AvatarURL.Valid {
 | 
				
			||||||
		fileName := filepath.Base(user.AvatarURL.String)
 | 
							fileName := filepath.Base(user.AvatarURL.String)
 | 
				
			||||||
		app.media.Delete(fileName)
 | 
							if err := app.media.Delete(fileName); err != nil {
 | 
				
			||||||
 | 
								app.lo.Error("error deleting user avatar", "user_id", user.ID, "error", err)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Save file path.
 | 
						// Save file path.
 | 
				
			||||||
	path, err := stringutil.GetPathFromURL(media.URL)
 | 
						path, err := stringutil.GetPathFromURL(media.URL)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		app.lo.Debug("error getting path from URL", "url", media.URL, "error", err)
 | 
							app.lo.Debug("error getting path from URL", "user_id", user.ID, "url", media.URL, "error", err)
 | 
				
			||||||
		return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorUploading", "name", "{globals.terms.file}"), nil)
 | 
							return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorUploading", "name", "{globals.terms.file}"), nil)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	fmt.Println("path", path)
 | 
					
 | 
				
			||||||
	if err := app.user.UpdateAvatar(user.ID, path); err != nil {
 | 
						if err := app.user.UpdateAvatar(user.ID, path); err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return nil
 | 
						return nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// handleGenerateAPIKey generates a new API key for a user
 | 
				
			||||||
 | 
					func handleGenerateAPIKey(r *fastglue.Request) error {
 | 
				
			||||||
 | 
						var (
 | 
				
			||||||
 | 
							app   = r.Context.(*App)
 | 
				
			||||||
 | 
							id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
 | 
				
			||||||
 | 
						)
 | 
				
			||||||
 | 
						if id <= 0 {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Check if user exists
 | 
				
			||||||
 | 
						user, err := app.user.GetAgent(id, "")
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Generate API key and secret
 | 
				
			||||||
 | 
						apiKey, apiSecret, err := app.user.GenerateAPIKey(user.ID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Return the API key and secret (only shown once)
 | 
				
			||||||
 | 
						response := struct {
 | 
				
			||||||
 | 
							APIKey    string `json:"api_key"`
 | 
				
			||||||
 | 
							APISecret string `json:"api_secret"`
 | 
				
			||||||
 | 
						}{
 | 
				
			||||||
 | 
							APIKey:    apiKey,
 | 
				
			||||||
 | 
							APISecret: apiSecret,
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return r.SendEnvelope(response)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// handleRevokeAPIKey revokes a user's API key
 | 
				
			||||||
 | 
					func handleRevokeAPIKey(r *fastglue.Request) error {
 | 
				
			||||||
 | 
						var (
 | 
				
			||||||
 | 
							app   = r.Context.(*App)
 | 
				
			||||||
 | 
							id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
 | 
				
			||||||
 | 
						)
 | 
				
			||||||
 | 
						if id <= 0 {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Check if user exists
 | 
				
			||||||
 | 
						_, err := app.user.GetAgent(id, "")
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Revoke API key
 | 
				
			||||||
 | 
						if err := app.user.RevokeAPIKey(id); err != nil {
 | 
				
			||||||
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return r.SendEnvelope(true)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// validateAgentRequest validates common agent request fields and normalizes the email
 | 
				
			||||||
 | 
					func validateAgentRequest(r *fastglue.Request, req *agentReq) error {
 | 
				
			||||||
 | 
						var app = r.Context.(*App)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Normalize email
 | 
				
			||||||
 | 
						req.Email = strings.TrimSpace(strings.ToLower(req.Email))
 | 
				
			||||||
 | 
						if req.Email == "" {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`email`"), nil, envelope.InputError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if !stringutil.ValidEmail(req.Email) {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`email`"), nil, envelope.InputError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if req.Roles == nil {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`role`"), nil, envelope.InputError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if req.FirstName == "" {
 | 
				
			||||||
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`first_name`"), nil, envelope.InputError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										10
									
								
								cmd/views.go
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								cmd/views.go
									
									
									
									
									
								
							@@ -47,10 +47,11 @@ func handleCreateUserView(r *fastglue.Request) error {
 | 
				
			|||||||
	if string(view.Filters) == "" {
 | 
						if string(view.Filters) == "" {
 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`Filters`"), nil, envelope.InputError)
 | 
							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 sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return r.SendEnvelope(true)
 | 
						return r.SendEnvelope(createdView)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// handleDeleteUserView deletes a view for a user.
 | 
					// handleDeleteUserView deletes a view for a user.
 | 
				
			||||||
@@ -111,8 +112,9 @@ func handleUpdateUserView(r *fastglue.Request) error {
 | 
				
			|||||||
	if v.UserID != user.ID {
 | 
						if v.UserID != user.ID {
 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.PermissionError)
 | 
							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 sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return r.SendEnvelope(true)
 | 
						return r.SendEnvelope(updatedView)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										191
									
								
								cmd/webhooks.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										191
									
								
								cmd/webhooks.go
									
									
									
									
									
										Normal 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
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -1,80 +1,124 @@
 | 
				
			|||||||
# App.
 | 
					 | 
				
			||||||
[app]
 | 
					[app]
 | 
				
			||||||
 | 
					# Log level: info, debug, warn, error, fatal
 | 
				
			||||||
log_level = "debug"
 | 
					log_level = "debug"
 | 
				
			||||||
 | 
					# Environment: dev, prod.
 | 
				
			||||||
 | 
					# Setting to "dev" will enable color logging in terminal.
 | 
				
			||||||
env = "dev"
 | 
					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
 | 
					check_updates = true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# HTTP server.
 | 
					# HTTP server.
 | 
				
			||||||
[app.server]
 | 
					[app.server]
 | 
				
			||||||
 | 
					# Address to bind the HTTP server to.
 | 
				
			||||||
address = "0.0.0.0:9000"
 | 
					address = "0.0.0.0:9000"
 | 
				
			||||||
 | 
					# Unix socket path (leave empty to use TCP address instead)
 | 
				
			||||||
socket = ""
 | 
					socket = ""
 | 
				
			||||||
# Do NOT disable secure cookies in production environment if you don't know
 | 
					# Do NOT disable secure cookies in production environment if you don't know exactly what you're doing!
 | 
				
			||||||
# exactly what you're doing!
 | 
					 | 
				
			||||||
disable_secure_cookies = false
 | 
					disable_secure_cookies = false
 | 
				
			||||||
 | 
					# Request read and write timeouts.
 | 
				
			||||||
read_timeout = "5s"
 | 
					read_timeout = "5s"
 | 
				
			||||||
write_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
 | 
					read_buffer_size = 4096
 | 
				
			||||||
 | 
					# Keepalive settings.
 | 
				
			||||||
keepalive_timeout = "10s"
 | 
					keepalive_timeout = "10s"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# File upload provider to use, either `fs` or `s3`.
 | 
					# File upload provider to use, either `fs` or `s3`.
 | 
				
			||||||
[upload]
 | 
					[upload]
 | 
				
			||||||
provider = "fs"
 | 
					provider = "fs"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Filesytem provider.
 | 
					# Filesystem provider.
 | 
				
			||||||
[upload.fs]
 | 
					[upload.fs]
 | 
				
			||||||
 | 
					# Directory where uploaded files are stored, make sure this directory exists and is writable by the application.
 | 
				
			||||||
upload_path = 'uploads'
 | 
					upload_path = 'uploads'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# S3 provider.
 | 
					# S3 provider.
 | 
				
			||||||
[upload.s3]
 | 
					[upload.s3]
 | 
				
			||||||
 | 
					# S3 endpoint URL (required only for non-AWS S3-compatible providers like MinIO).
 | 
				
			||||||
 | 
					# Leave empty to use default AWS endpoints.
 | 
				
			||||||
url = ""
 | 
					url = ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# AWS S3 credentials, keep empty to use attached IAM roles.
 | 
				
			||||||
access_key = ""
 | 
					access_key = ""
 | 
				
			||||||
secret_key = ""
 | 
					secret_key = ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# AWS region, e.g., "us-east-1", "eu-west-1", etc.
 | 
				
			||||||
region = "ap-south-1"
 | 
					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 = ""
 | 
					bucket_path = ""
 | 
				
			||||||
expiry = "6h"
 | 
					# S3 signed URL expiry duration (e.g., "30m", "1h")
 | 
				
			||||||
 | 
					expiry = "30m"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Postgres.
 | 
					# Postgres.
 | 
				
			||||||
[db]
 | 
					[db]
 | 
				
			||||||
# If using docker compose, use the service name as the host. e.g. db
 | 
					# If running locally, use `localhost`.
 | 
				
			||||||
host = "127.0.0.1"
 | 
					host = "db"
 | 
				
			||||||
 | 
					# Database port, default is 5432.
 | 
				
			||||||
port = 5432
 | 
					port = 5432
 | 
				
			||||||
# Update the following values with your database credentials.
 | 
					# Update the following values with your database credentials.
 | 
				
			||||||
user = "libredesk"
 | 
					user = "libredesk"
 | 
				
			||||||
password = "libredesk"
 | 
					password = "libredesk"
 | 
				
			||||||
database = "libredesk"
 | 
					database = "libredesk"
 | 
				
			||||||
ssl_mode = "disable"
 | 
					ssl_mode = "disable"
 | 
				
			||||||
 | 
					# Maximum number of open database connections
 | 
				
			||||||
max_open = 30
 | 
					max_open = 30
 | 
				
			||||||
 | 
					# Maximum number of idle connections in the pool
 | 
				
			||||||
max_idle = 30
 | 
					max_idle = 30
 | 
				
			||||||
 | 
					# Maximum time a connection can be reused before being closed
 | 
				
			||||||
max_lifetime = "300s"
 | 
					max_lifetime = "300s"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Redis.
 | 
					# Redis.
 | 
				
			||||||
[redis]
 | 
					[redis]
 | 
				
			||||||
# If using docker compose, use the service name as the host. e.g. redis:6379
 | 
					# If running locally, use `localhost:6379`.
 | 
				
			||||||
address = "127.0.0.1:6379"
 | 
					address = "redis:6379"
 | 
				
			||||||
password = ""
 | 
					password = ""
 | 
				
			||||||
db = 0
 | 
					db = 0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[message]
 | 
					[message]
 | 
				
			||||||
 | 
					# Number of workers processing outgoing message queue
 | 
				
			||||||
outgoing_queue_workers = 10
 | 
					outgoing_queue_workers = 10
 | 
				
			||||||
 | 
					# Number of workers processing incoming message queue
 | 
				
			||||||
incoming_queue_workers = 10
 | 
					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
 | 
					incoming_queue_size = 5000
 | 
				
			||||||
 | 
					# Maximum number of messages that can be queued for outgoing processing
 | 
				
			||||||
outgoing_queue_size = 5000
 | 
					outgoing_queue_size = 5000
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[notification]
 | 
					[notification]
 | 
				
			||||||
 | 
					# Number of concurrent notification workers
 | 
				
			||||||
concurrency = 2
 | 
					concurrency = 2
 | 
				
			||||||
 | 
					# Maximum number of notifications that can be queued
 | 
				
			||||||
queue_size = 2000
 | 
					queue_size = 2000
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[automation]
 | 
					[automation]
 | 
				
			||||||
 | 
					# Number of workers processing automation rules
 | 
				
			||||||
worker_count = 10
 | 
					worker_count = 10
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[autoassigner]
 | 
					[autoassigner]
 | 
				
			||||||
 | 
					# How often to run automatic conversation assignment
 | 
				
			||||||
autoassign_interval = "5m"
 | 
					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]
 | 
					[conversation]
 | 
				
			||||||
 | 
					# How often to check for conversations to unsnooze
 | 
				
			||||||
unsnooze_interval = "5m"
 | 
					unsnooze_interval = "5m"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[sla]
 | 
					[sla]
 | 
				
			||||||
 | 
					# How often to evaluate SLA compliance for conversations
 | 
				
			||||||
evaluation_interval = "5m"
 | 
					evaluation_interval = "5m"
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,31 +0,0 @@
 | 
				
			|||||||
# Developer Setup
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
Libredesk is a monorepo with a Go backend and a Vue.js frontend. The frontend uses Shadcn for UI components.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
### Pre-requisites
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
- `go`
 | 
					 | 
				
			||||||
- `nodejs` (if you are working on the frontend) and `pnpm`
 | 
					 | 
				
			||||||
- Postgres database (>= 13)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
### First time setup
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
Clone the repository:
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
```sh
 | 
					 | 
				
			||||||
git clone https://github.com/abhinavxd/libredesk.git
 | 
					 | 
				
			||||||
```
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
1. Copy `config.toml.sample` as `config.toml` and add your config.
 | 
					 | 
				
			||||||
2. Run `make` to build the libredesk binary. Once the binary is built, run `./libredesk --install` to run the DB setup and set the System user password.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
### Running the Dev Environment
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
1. Run `make run-backend` to start the libredesk backend dev server on `:9000`.
 | 
					 | 
				
			||||||
2. Run `make run-frontend` to start the Vue frontend in dev mode using pnpm on `:8000`. Requests are proxied to the backend running on `:9000` check `vite.config.js` for the proxy config.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
---
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Production Build
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
Run `make` to build the Go binary, build the Javascript frontend, and embed the static assets producing a single self-contained binary, `libredesk`.
 | 
					 | 
				
			||||||
@@ -1,13 +0,0 @@
 | 
				
			|||||||
# Introduction
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
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>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
## 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.
 | 
					 | 
				
			||||||
@@ -1,67 +0,0 @@
 | 
				
			|||||||
# Installation
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
Libredesk is a single binary application that requires postgres and redis to run. You can install it using the binary or docker.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
## Binary
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
1. Download the [latest release](https://github.com/abhinavxd/libredesk/releases) and extract the libredesk binary.
 | 
					 | 
				
			||||||
2. `./libredesk --install` to install the tables in the Postgres DB (⩾ 13) and set the System user password.
 | 
					 | 
				
			||||||
3. Run `./libredesk` and visit `http://localhost:9000` and login with the email `System` and the password you set during installation.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
!!! Tip
 | 
					 | 
				
			||||||
    To set the System user password during installation, set the environment variables:
 | 
					 | 
				
			||||||
    `LIBREDESK_SYSTEM_USER_PASSWORD=xxxxxxxxxxx ./libredesk --install`
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
## Docker
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
The latest image is available on DockerHub at `libredesk/libredesk:latest`
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
The recommended method is to download the [docker-compose.yml](https://github.com/abhinavxd/libredesk/blob/main/docker-compose.yml) file, customize it for your environment and then to simply run `docker compose up -d`.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
```shell
 | 
					 | 
				
			||||||
# Download the compose file and the sample config file in the current directory.
 | 
					 | 
				
			||||||
curl -LO https://github.com/abhinavxd/libredesk/raw/main/docker-compose.yml
 | 
					 | 
				
			||||||
curl -LO https://github.com/abhinavxd/libredesk/raw/main/config.sample.toml
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Copy the config.sample.toml to config.toml and edit it as needed.
 | 
					 | 
				
			||||||
cp config.sample.toml config.toml
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# 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
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Setting System user password.
 | 
					 | 
				
			||||||
docker exec -it libredesk_app ./libredesk --set-system-user-password
 | 
					 | 
				
			||||||
```
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
Go to `http://localhost:9000` and login with the email `System` and the password you set using the `--set-system-user-password` command.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
## Compiling from source
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
To compile the latest unreleased version (`main` branch):
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
1. Make sure `go`, `nodejs`, and `pnpm` are installed on your system.
 | 
					 | 
				
			||||||
2. `git clone git@github.com:abhinavxd/libredesk.git`
 | 
					 | 
				
			||||||
3. `cd libredesk && make`. This will generate the `libredesk` binary.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
## Nginx
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
Libredesk uses websockets for real-time updates. If you are using Nginx, you need to add the following (or similar) configuration to your Nginx configuration file.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
```nginx
 | 
					 | 
				
			||||||
client_max_body_size 100M;
 | 
					 | 
				
			||||||
location / {
 | 
					 | 
				
			||||||
    proxy_pass http://localhost:9000;
 | 
					 | 
				
			||||||
    proxy_http_version 1.1;
 | 
					 | 
				
			||||||
    proxy_set_header Upgrade $http_upgrade;
 | 
					 | 
				
			||||||
    proxy_set_header Connection 'upgrade';
 | 
					 | 
				
			||||||
    proxy_set_header Host $host;
 | 
					 | 
				
			||||||
    proxy_set_header X-Real-IP $remote_addr;
 | 
					 | 
				
			||||||
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
 | 
					 | 
				
			||||||
    proxy_cache_bypass $http_upgrade;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
```
 | 
					 | 
				
			||||||
@@ -1,57 +0,0 @@
 | 
				
			|||||||
# Setting up SSO
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
Libredesk supports external OpenID Connect providers (e.g., Google, Keycloak) for signing in users.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
!!! note
 | 
					 | 
				
			||||||
    User accounts must be created in Libredesk manually; signup is not supported.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
## Generic Configuration Steps
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
Since each provider’s configuration might differ, consult your provider’s documentation for any additional or divergent settings.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
1. Provider setup:  
 | 
					 | 
				
			||||||
   In your provider’s admin console, create a new OpenID Connect application/client. Retrieve:
 | 
					 | 
				
			||||||
      - Client ID
 | 
					 | 
				
			||||||
      - Client Secret
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
2. Libredesk configuration: 
 | 
					 | 
				
			||||||
   In Libredesk, navigate to Security > SSO and click New SSO and enter the following details:
 | 
					 | 
				
			||||||
      - Provider URL (e.g., the URL of your OpenID provider)
 | 
					 | 
				
			||||||
      - Client ID
 | 
					 | 
				
			||||||
      - Client Secret
 | 
					 | 
				
			||||||
      - A descriptive name for the connection
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
3. Redirect URL:  
 | 
					 | 
				
			||||||
   After saving, copy the generated Callback URL from Libredesk and add it as a valid redirect URI in your provider’s client settings.
 | 
					 | 
				
			||||||
   
 | 
					 | 
				
			||||||
## Provider Examples
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#### Keycloak
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
1. Log in to your Keycloak Admin Console.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
2. In Keycloak, navigate to Clients and click Create:
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      - Client ID (e.g., `libredesk-app`)
 | 
					 | 
				
			||||||
      - Client Protocol: `openid-connect`
 | 
					 | 
				
			||||||
      - Root URL and Web Origins: your app domain (e.g., `https://ticket.example.com`)
 | 
					 | 
				
			||||||
      - Under Authentication flow, uncheck everything except the standard flow
 | 
					 | 
				
			||||||
      - Click save
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
3. Go to the credentials tab:
 | 
					 | 
				
			||||||
      - Ensure client authenticator is set to `Client Id and Secret`
 | 
					 | 
				
			||||||
      - Note down the generated client secret
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
4. In Libredesk, go to Admin > Security > SSO and click New SSO:
 | 
					 | 
				
			||||||
      - Provider URL (e.g., `https://keycloak.example.com/realms/yourrealm`)
 | 
					 | 
				
			||||||
      - Name (e.g., `Keycloak`)
 | 
					 | 
				
			||||||
      - Client ID
 | 
					 | 
				
			||||||
      - Client secret
 | 
					 | 
				
			||||||
      - Click save
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
5. After saving, click on the three dots and choose Edit to open the new SSO entry.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
6. Copy the generated Callback URL from Libredesk.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
7. Back in Keycloak, edit the client and add the Callback URL to Valid Redirect URIs:
 | 
					 | 
				
			||||||
      - e.g., `https://ticket.example.com/api/v1/oidc/1/finish`
 | 
					 | 
				
			||||||
@@ -1,43 +0,0 @@
 | 
				
			|||||||
# Templating
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
Templating in outgoing emails allows you to personalize content by embedding dynamic expressions like `{{ .Recipient.FullName }}`. These expressions reference fields from the conversation, contact, and recipient objects.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
## Outgoing Email Template Expressions
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
If you want to customize the look of outgoing emails, you can do so in the Admin > Templates -> Outgoing Email Templates section. This template will be used for all outgoing emails including replies to conversations, notifications, and other system-generated emails.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
### Conversation Variables
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
| Variable                        | Value                                                  |
 | 
					 | 
				
			||||||
|---------------------------------|--------------------------------------------------------|
 | 
					 | 
				
			||||||
| {{ .Conversation.ReferenceNumber }} | The unique reference number of the conversation     |
 | 
					 | 
				
			||||||
| {{ .Conversation.Subject }}         | The subject of the conversation                     |
 | 
					 | 
				
			||||||
| {{ .Conversation.UUID }}           | The unique identifier of the conversation            |
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
### Contact Variables
 | 
					 | 
				
			||||||
| Variable                     | Value                              |
 | 
					 | 
				
			||||||
|------------------------------|------------------------------------|
 | 
					 | 
				
			||||||
| {{ .Contact.FirstName }}     | First name of the contact/customer |
 | 
					 | 
				
			||||||
| {{ .Contact.LastName }}      | Last name of the contact/customer  |
 | 
					 | 
				
			||||||
| {{ .Contact.FullName }}      | Full name of the contact/customer  |
 | 
					 | 
				
			||||||
| {{ .Contact.Email }}         | Email address of the contact/customer |
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
### Recipient Variables
 | 
					 | 
				
			||||||
| Variable                       | Value                             |
 | 
					 | 
				
			||||||
|--------------------------------|-----------------------------------|
 | 
					 | 
				
			||||||
| {{ .Recipient.FirstName }}     | First name of the recipient       |
 | 
					 | 
				
			||||||
| {{ .Recipient.LastName }}      | Last name of the recipient        |
 | 
					 | 
				
			||||||
| {{ .Recipient.FullName }}      | Full name of the recipient        |
 | 
					 | 
				
			||||||
| {{ .Recipient.Email }}         | Email address of the recipient    |
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
### Example outgoing email template
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
```html
 | 
					 | 
				
			||||||
Dear {{ .Recipient.FirstName }}
 | 
					 | 
				
			||||||
{{ template "content" . }}
 | 
					 | 
				
			||||||
Best regards,
 | 
					 | 
				
			||||||
```
 | 
					 | 
				
			||||||
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.
 | 
					 | 
				
			||||||
@@ -1,3 +0,0 @@
 | 
				
			|||||||
# Translations / Internationalization
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
You can help translate libreDesk into different languages by contributing here: [LibreDesk Translation Project](https://crowdin.com/project/libredesk)
 | 
					 | 
				
			||||||
@@ -1,18 +0,0 @@
 | 
				
			|||||||
# Upgrade
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
!!! warning "Warning"
 | 
					 | 
				
			||||||
    Always take a backup of the Postgres database before upgrading Libredesk.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
## Binary
 | 
					 | 
				
			||||||
- Stop running libredesk binary.
 | 
					 | 
				
			||||||
- Download the [latest release](https://github.com/abhinavxd/libredesk/releases) and extract the libredesk binary and overwrite the previous version.
 | 
					 | 
				
			||||||
- `./libredesk --upgrade` to upgrade an existing database schema. Upgrades are idempotent and running them multiple times have no side effects.
 | 
					 | 
				
			||||||
- Run `./libredesk` again.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
## Docker
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
```shell
 | 
					 | 
				
			||||||
docker compose down app
 | 
					 | 
				
			||||||
docker compose pull
 | 
					 | 
				
			||||||
docker compose up app -d
 | 
					 | 
				
			||||||
```
 | 
					 | 
				
			||||||
@@ -1,38 +0,0 @@
 | 
				
			|||||||
site_name: Libredesk Documentation
 | 
					 | 
				
			||||||
theme:
 | 
					 | 
				
			||||||
  name: material
 | 
					 | 
				
			||||||
  language: en
 | 
					 | 
				
			||||||
  font:
 | 
					 | 
				
			||||||
    text: Source Sans Pro
 | 
					 | 
				
			||||||
    code: Roboto Mono
 | 
					 | 
				
			||||||
    weights: 
 | 
					 | 
				
			||||||
      - 400
 | 
					 | 
				
			||||||
      - 700
 | 
					 | 
				
			||||||
  direction: ltr
 | 
					 | 
				
			||||||
  palette:
 | 
					 | 
				
			||||||
    primary: white
 | 
					 | 
				
			||||||
    accent: red
 | 
					 | 
				
			||||||
  features:
 | 
					 | 
				
			||||||
    - navigation.indexes
 | 
					 | 
				
			||||||
    - navigation.sections
 | 
					 | 
				
			||||||
    - content.code.copy
 | 
					 | 
				
			||||||
  extra:
 | 
					 | 
				
			||||||
    search:
 | 
					 | 
				
			||||||
      language: en
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
markdown_extensions:
 | 
					 | 
				
			||||||
  - admonition
 | 
					 | 
				
			||||||
  - codehilite
 | 
					 | 
				
			||||||
  - toc:
 | 
					 | 
				
			||||||
      permalink: true
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
nav:
 | 
					 | 
				
			||||||
  - Introduction: index.md
 | 
					 | 
				
			||||||
  - Getting Started:
 | 
					 | 
				
			||||||
      - Installation: installation.md
 | 
					 | 
				
			||||||
      - Upgrade: upgrade.md
 | 
					 | 
				
			||||||
      - Templating: templating.md
 | 
					 | 
				
			||||||
      - SSO: sso.md
 | 
					 | 
				
			||||||
  - Contributors:
 | 
					 | 
				
			||||||
      - Developer setup: developer-setup.md
 | 
					 | 
				
			||||||
      - Translations: translations.md
 | 
					 | 
				
			||||||
@@ -2,23 +2,33 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
describe('Login Component', () => {
 | 
					describe('Login Component', () => {
 | 
				
			||||||
    beforeEach(() => {
 | 
					    beforeEach(() => {
 | 
				
			||||||
        // Visit the login page
 | 
					 | 
				
			||||||
        cy.visit('/')
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        // Mock the API response for OIDC providers
 | 
					        // Mock the API response for OIDC providers
 | 
				
			||||||
        cy.intercept('GET', '**/api/v1/oidc/enabled', {
 | 
					        cy.intercept('GET', '**/api/v1/config', {
 | 
				
			||||||
            statusCode: 200,
 | 
					            statusCode: 200,
 | 
				
			||||||
            body: {
 | 
					            body: {
 | 
				
			||||||
                data: [
 | 
					                data: {
 | 
				
			||||||
                    {
 | 
					                    "app.favicon_url": "http://localhost:9000/favicon.ico",
 | 
				
			||||||
                        id: 1,
 | 
					                    "app.lang": "en",
 | 
				
			||||||
                        name: 'Google',
 | 
					                    "app.logo_url": "http://localhost:9000/logo.png",
 | 
				
			||||||
                        logo_url: 'https://example.com/google-logo.png',
 | 
					                    "app.site_name": "Libredesk",
 | 
				
			||||||
                        disabled: false
 | 
					                    "app.sso_providers": [
 | 
				
			||||||
                    }
 | 
					                        {
 | 
				
			||||||
                ]
 | 
					                            "client_id": "xx",
 | 
				
			||||||
 | 
					                            "enabled": true,
 | 
				
			||||||
 | 
					                            "id": 1,
 | 
				
			||||||
 | 
					                            "logo_url": "/images/google-logo.png",
 | 
				
			||||||
 | 
					                            "name": "Google",
 | 
				
			||||||
 | 
					                            "provider": "Google",
 | 
				
			||||||
 | 
					                            "provider_url": "https://accounts.google.com",
 | 
				
			||||||
 | 
					                            "redirect_uri": "http://localhost:9000/api/v1/oidc/1/finish"
 | 
				
			||||||
 | 
					                        }
 | 
				
			||||||
 | 
					                    ]
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        }).as('getOIDCProviders')
 | 
					        }).as('getOIDCProviders')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Visit the login page
 | 
				
			||||||
 | 
					        cy.visit('/')
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    it('should display login form', () => {
 | 
					    it('should display login form', () => {
 | 
				
			||||||
@@ -38,7 +48,7 @@ describe('Login Component', () => {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    it('should show error for invalid login attempt', () => {
 | 
					    it('should show error for invalid login attempt', () => {
 | 
				
			||||||
        // Mock failed login API call
 | 
					        // Mock failed login API call
 | 
				
			||||||
        cy.intercept('POST', '**/api/v1/login', {
 | 
					        cy.intercept('POST', '**/api/v1/auth/login', {
 | 
				
			||||||
            statusCode: 401,
 | 
					            statusCode: 401,
 | 
				
			||||||
            body: {
 | 
					            body: {
 | 
				
			||||||
                message: 'Invalid credentials'
 | 
					                message: 'Invalid credentials'
 | 
				
			||||||
@@ -61,7 +71,7 @@ describe('Login Component', () => {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    it('should login successfully with correct credentials', () => {
 | 
					    it('should login successfully with correct credentials', () => {
 | 
				
			||||||
        // Mock successful login API call
 | 
					        // Mock successful login API call
 | 
				
			||||||
        cy.intercept('POST', '**/api/v1/login', {
 | 
					        cy.intercept('POST', '**/api/v1/auth/login', {
 | 
				
			||||||
            statusCode: 200,
 | 
					            statusCode: 200,
 | 
				
			||||||
            body: {
 | 
					            body: {
 | 
				
			||||||
                data: {
 | 
					                data: {
 | 
				
			||||||
@@ -111,7 +121,7 @@ describe('Login Component', () => {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    it('should show loading state during login', () => {
 | 
					    it('should show loading state during login', () => {
 | 
				
			||||||
        // Mock slow API response
 | 
					        // Mock slow API response
 | 
				
			||||||
        cy.intercept('POST', '**/api/v1/login', {
 | 
					        cy.intercept('POST', '**/api/v1/auth/login', {
 | 
				
			||||||
            statusCode: 200,
 | 
					            statusCode: 200,
 | 
				
			||||||
            body: {
 | 
					            body: {
 | 
				
			||||||
                data: {
 | 
					                data: {
 | 
				
			||||||
@@ -132,7 +142,7 @@ describe('Login Component', () => {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        // Check if loading state is shown
 | 
					        // Check if loading state is shown
 | 
				
			||||||
        cy.contains('Logging in...').should('be.visible')
 | 
					        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
 | 
					        // Wait for API call to finish
 | 
				
			||||||
        cy.wait('@slowLogin')
 | 
					        cy.wait('@slowLogin')
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,12 +1,14 @@
 | 
				
			|||||||
{
 | 
					{
 | 
				
			||||||
  "name": "libredesk",
 | 
					  "name": "libredesk",
 | 
				
			||||||
  "version": "0.6.0-alpha",
 | 
					  "version": "0.8.0-beta",
 | 
				
			||||||
  "private": true,
 | 
					  "private": true,
 | 
				
			||||||
  "type": "module",
 | 
					  "type": "module",
 | 
				
			||||||
  "scripts": {
 | 
					  "scripts": {
 | 
				
			||||||
    "dev": "pnpm exec vite",
 | 
					    "dev": "pnpm exec vite",
 | 
				
			||||||
    "build": "vite build",
 | 
					    "build": "vite build",
 | 
				
			||||||
    "preview": "vite preview",
 | 
					    "preview": "vite preview",
 | 
				
			||||||
 | 
					    "test": "vitest",
 | 
				
			||||||
 | 
					    "test:run": "vitest run",
 | 
				
			||||||
    "test:e2e": "start-server-and-test preview http://localhost:4173 'cypress run --e2e'",
 | 
					    "test:e2e": "start-server-and-test preview http://localhost:4173 'cypress run --e2e'",
 | 
				
			||||||
    "test:e2e:ci": "cypress run --e2e --headless",
 | 
					    "test:e2e:ci": "cypress run --e2e --headless",
 | 
				
			||||||
    "test:e2e:dev": "start-server-and-test 'vite dev --port 4173' http://localhost:4173 'cypress open --e2e'",
 | 
					    "test:e2e:dev": "start-server-and-test 'vite dev --port 4173' http://localhost:4173 'cypress open --e2e'",
 | 
				
			||||||
@@ -16,6 +18,8 @@
 | 
				
			|||||||
    "format": "prettier --write src/"
 | 
					    "format": "prettier --write src/"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "dependencies": {
 | 
					  "dependencies": {
 | 
				
			||||||
 | 
					    "@codemirror/lang-html": "^6.4.9",
 | 
				
			||||||
 | 
					    "@codemirror/theme-one-dark": "^6.1.3",
 | 
				
			||||||
    "@formkit/auto-animate": "^0.8.2",
 | 
					    "@formkit/auto-animate": "^0.8.2",
 | 
				
			||||||
    "@internationalized/date": "^3.5.5",
 | 
					    "@internationalized/date": "^3.5.5",
 | 
				
			||||||
    "@radix-icons/vue": "^1.0.0",
 | 
					    "@radix-icons/vue": "^1.0.0",
 | 
				
			||||||
@@ -35,10 +39,10 @@
 | 
				
			|||||||
    "@unovis/vue": "^1.4.4",
 | 
					    "@unovis/vue": "^1.4.4",
 | 
				
			||||||
    "@vee-validate/zod": "^4.15.0",
 | 
					    "@vee-validate/zod": "^4.15.0",
 | 
				
			||||||
    "@vueuse/core": "^12.4.0",
 | 
					    "@vueuse/core": "^12.4.0",
 | 
				
			||||||
    "axios": "^1.8.2",
 | 
					    "axios": "^1.12.0",
 | 
				
			||||||
    "class-variance-authority": "^0.7.0",
 | 
					    "class-variance-authority": "^0.7.0",
 | 
				
			||||||
    "clsx": "^2.1.1",
 | 
					    "clsx": "^2.1.1",
 | 
				
			||||||
    "codeflask": "^1.4.1",
 | 
					    "codemirror": "^6.0.2",
 | 
				
			||||||
    "date-fns": "^3.6.0",
 | 
					    "date-fns": "^3.6.0",
 | 
				
			||||||
    "lucide-vue-next": "^0.378.0",
 | 
					    "lucide-vue-next": "^0.378.0",
 | 
				
			||||||
    "mitt": "^3.0.1",
 | 
					    "mitt": "^3.0.1",
 | 
				
			||||||
@@ -74,7 +78,8 @@
 | 
				
			|||||||
    "start-server-and-test": "^2.0.3",
 | 
					    "start-server-and-test": "^2.0.3",
 | 
				
			||||||
    "tailwindcss": "^3.4.17",
 | 
					    "tailwindcss": "^3.4.17",
 | 
				
			||||||
    "tailwindcss-animate": "^1.0.7",
 | 
					    "tailwindcss-animate": "^1.0.7",
 | 
				
			||||||
    "vite": "^5.4.18"
 | 
					    "vite": "^5.4.20",
 | 
				
			||||||
 | 
					    "vitest": "^3.2.2"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "packageManager": "pnpm@9.15.3+sha512.1f79bc245a66eb0b07c5d4d83131240774642caaa86ef7d0434ab47c0d16f66b04e21e0c086eb61e62c77efc4d7f7ec071afad3796af64892fae66509173893a"
 | 
					  "packageManager": "pnpm@9.15.3+sha512.1f79bc245a66eb0b07c5d4d83131240774642caaa86ef7d0434ab47c0d16f66b04e21e0c086eb61e62c77efc4d7f7ec071afad3796af64892fae66509173893a"
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
							
								
								
									
										841
									
								
								frontend/pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										841
									
								
								frontend/pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -8,38 +8,64 @@
 | 
				
			|||||||
            <SidebarGroupContent>
 | 
					            <SidebarGroupContent>
 | 
				
			||||||
              <SidebarMenu>
 | 
					              <SidebarMenu>
 | 
				
			||||||
                <SidebarMenuItem>
 | 
					                <SidebarMenuItem>
 | 
				
			||||||
                  <SidebarMenuButton asChild :isActive="route.path.startsWith('/inboxes')">
 | 
					                  <Tooltip>
 | 
				
			||||||
                    <router-link :to="{ name: 'inboxes' }">
 | 
					                    <TooltipTrigger as-child>
 | 
				
			||||||
                      <Inbox />
 | 
					                      <SidebarMenuButton asChild :isActive="route.path.startsWith('/inboxes')">
 | 
				
			||||||
                    </router-link>
 | 
					                        <router-link :to="{ name: 'inboxes' }">
 | 
				
			||||||
                  </SidebarMenuButton>
 | 
					                          <Inbox />
 | 
				
			||||||
 | 
					                        </router-link>
 | 
				
			||||||
 | 
					                      </SidebarMenuButton>
 | 
				
			||||||
 | 
					                    </TooltipTrigger>
 | 
				
			||||||
 | 
					                    <TooltipContent side="right">
 | 
				
			||||||
 | 
					                      <p>{{ t('globals.terms.inbox', 2) }}</p>
 | 
				
			||||||
 | 
					                    </TooltipContent>
 | 
				
			||||||
 | 
					                  </Tooltip>
 | 
				
			||||||
                </SidebarMenuItem>
 | 
					                </SidebarMenuItem>
 | 
				
			||||||
                <SidebarMenuItem>
 | 
					                <SidebarMenuItem v-if="userStore.can('contacts:read_all')">
 | 
				
			||||||
                  <SidebarMenuButton
 | 
					                  <Tooltip>
 | 
				
			||||||
                    asChild
 | 
					                    <TooltipTrigger as-child>
 | 
				
			||||||
                    :isActive="route.path.startsWith('/contacts')"
 | 
					                      <SidebarMenuButton asChild :isActive="route.path.startsWith('/contacts')">
 | 
				
			||||||
                    v-if="userStore.can('contacts:read_all')"
 | 
					                        <router-link :to="{ name: 'contacts' }">
 | 
				
			||||||
                  >
 | 
					                          <BookUser />
 | 
				
			||||||
                    <router-link :to="{ name: 'contacts' }">
 | 
					                        </router-link>
 | 
				
			||||||
                      <BookUser />
 | 
					                      </SidebarMenuButton>
 | 
				
			||||||
                    </router-link>
 | 
					                    </TooltipTrigger>
 | 
				
			||||||
                  </SidebarMenuButton>
 | 
					                    <TooltipContent side="right">
 | 
				
			||||||
 | 
					                      <p>{{ t('globals.terms.contact', 2) }}</p>
 | 
				
			||||||
 | 
					                    </TooltipContent>
 | 
				
			||||||
 | 
					                  </Tooltip>
 | 
				
			||||||
                </SidebarMenuItem>
 | 
					                </SidebarMenuItem>
 | 
				
			||||||
                <SidebarMenuItem v-if="userStore.hasReportTabPermissions">
 | 
					                <SidebarMenuItem v-if="userStore.hasReportTabPermissions">
 | 
				
			||||||
                  <SidebarMenuButton asChild :isActive="route.path.startsWith('/reports')">
 | 
					                  <Tooltip>
 | 
				
			||||||
                    <router-link :to="{ name: 'reports' }">
 | 
					                    <TooltipTrigger as-child>
 | 
				
			||||||
                      <FileLineChart />
 | 
					                      <SidebarMenuButton asChild :isActive="route.path.startsWith('/reports')">
 | 
				
			||||||
                    </router-link>
 | 
					                        <router-link :to="{ name: 'reports' }">
 | 
				
			||||||
                  </SidebarMenuButton>
 | 
					                          <FileLineChart />
 | 
				
			||||||
 | 
					                        </router-link>
 | 
				
			||||||
 | 
					                      </SidebarMenuButton>
 | 
				
			||||||
 | 
					                    </TooltipTrigger>
 | 
				
			||||||
 | 
					                    <TooltipContent side="right">
 | 
				
			||||||
 | 
					                      <p>{{ t('globals.terms.report', 2) }}</p>
 | 
				
			||||||
 | 
					                    </TooltipContent>
 | 
				
			||||||
 | 
					                  </Tooltip>
 | 
				
			||||||
                </SidebarMenuItem>
 | 
					                </SidebarMenuItem>
 | 
				
			||||||
                <SidebarMenuItem v-if="userStore.hasAdminTabPermissions">
 | 
					                <SidebarMenuItem v-if="userStore.hasAdminTabPermissions">
 | 
				
			||||||
                  <SidebarMenuButton asChild :isActive="route.path.startsWith('/admin')">
 | 
					                  <Tooltip>
 | 
				
			||||||
                    <router-link
 | 
					                    <TooltipTrigger as-child>
 | 
				
			||||||
                      :to="{ name: userStore.can('general_settings:manage') ? 'general' : 'admin' }"
 | 
					                      <SidebarMenuButton asChild :isActive="route.path.startsWith('/admin')">
 | 
				
			||||||
                    >
 | 
					                        <router-link
 | 
				
			||||||
                      <Shield />
 | 
					                          :to="{
 | 
				
			||||||
                    </router-link>
 | 
					                            name: userStore.can('general_settings:manage') ? 'general' : 'admin'
 | 
				
			||||||
                  </SidebarMenuButton>
 | 
					                          }"
 | 
				
			||||||
 | 
					                        >
 | 
				
			||||||
 | 
					                          <Shield />
 | 
				
			||||||
 | 
					                        </router-link>
 | 
				
			||||||
 | 
					                      </SidebarMenuButton>
 | 
				
			||||||
 | 
					                    </TooltipTrigger>
 | 
				
			||||||
 | 
					                    <TooltipContent side="right">
 | 
				
			||||||
 | 
					                      <p>{{ t('globals.terms.admin') }}</p>
 | 
				
			||||||
 | 
					                    </TooltipContent>
 | 
				
			||||||
 | 
					                  </Tooltip>
 | 
				
			||||||
                </SidebarMenuItem>
 | 
					                </SidebarMenuItem>
 | 
				
			||||||
              </SidebarMenu>
 | 
					              </SidebarMenu>
 | 
				
			||||||
            </SidebarGroupContent>
 | 
					            </SidebarGroupContent>
 | 
				
			||||||
@@ -62,8 +88,8 @@
 | 
				
			|||||||
        @create-conversation="() => (openCreateConversationDialog = true)"
 | 
					        @create-conversation="() => (openCreateConversationDialog = true)"
 | 
				
			||||||
      >
 | 
					      >
 | 
				
			||||||
        <div class="flex flex-col h-screen">
 | 
					        <div class="flex flex-col h-screen">
 | 
				
			||||||
          <!-- Show app update only in admin routes -->
 | 
					          <!-- Show admin banner only in admin routes -->
 | 
				
			||||||
          <AppUpdate v-if="route.path.startsWith('/admin')" />
 | 
					          <AdminBanner v-if="route.path.startsWith('/admin')" />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          <!-- Common header for all pages -->
 | 
					          <!-- Common header for all pages -->
 | 
				
			||||||
          <PageHeader />
 | 
					          <PageHeader />
 | 
				
			||||||
@@ -80,7 +106,7 @@
 | 
				
			|||||||
  <Command />
 | 
					  <Command />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  <!-- Create conversation dialog -->
 | 
					  <!-- Create conversation dialog -->
 | 
				
			||||||
  <CreateConversation v-model="openCreateConversationDialog" />
 | 
					  <CreateConversation v-model="openCreateConversationDialog" v-if="openCreateConversationDialog" />
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
@@ -102,7 +128,7 @@ import { useCustomAttributeStore } from '@/stores/customAttributes'
 | 
				
			|||||||
import { useIdleDetection } from '@/composables/useIdleDetection'
 | 
					import { useIdleDetection } from '@/composables/useIdleDetection'
 | 
				
			||||||
import PageHeader from './components/layout/PageHeader.vue'
 | 
					import PageHeader from './components/layout/PageHeader.vue'
 | 
				
			||||||
import ViewForm from '@/features/view/ViewForm.vue'
 | 
					import ViewForm from '@/features/view/ViewForm.vue'
 | 
				
			||||||
import AppUpdate from '@/components/update/AppUpdate.vue'
 | 
					import AdminBanner from '@/components/banner/AdminBanner.vue'
 | 
				
			||||||
import api from '@/api'
 | 
					import api from '@/api'
 | 
				
			||||||
import { toast as sooner } from 'vue-sonner'
 | 
					import { toast as sooner } from 'vue-sonner'
 | 
				
			||||||
import Sidebar from '@/components/sidebar/Sidebar.vue'
 | 
					import Sidebar from '@/components/sidebar/Sidebar.vue'
 | 
				
			||||||
@@ -122,6 +148,7 @@ import {
 | 
				
			|||||||
  SidebarMenuItem,
 | 
					  SidebarMenuItem,
 | 
				
			||||||
  SidebarProvider
 | 
					  SidebarProvider
 | 
				
			||||||
} from '@/components/ui/sidebar'
 | 
					} from '@/components/ui/sidebar'
 | 
				
			||||||
 | 
					import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
 | 
				
			||||||
import SidebarNavUser from '@/components/sidebar/SidebarNavUser.vue'
 | 
					import SidebarNavUser from '@/components/sidebar/SidebarNavUser.vue'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const route = useRoute()
 | 
					const route = useRoute()
 | 
				
			||||||
@@ -185,7 +212,6 @@ const deleteView = async (view) => {
 | 
				
			|||||||
    })
 | 
					    })
 | 
				
			||||||
  } catch (err) {
 | 
					  } catch (err) {
 | 
				
			||||||
    emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
					    emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
				
			||||||
      title: 'Error',
 | 
					 | 
				
			||||||
      variant: 'destructive',
 | 
					      variant: 'destructive',
 | 
				
			||||||
      description: handleHTTPError(err).message
 | 
					      description: handleHTTPError(err).message
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -7,15 +7,15 @@ const http = axios.create({
 | 
				
			|||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function getCSRFToken () {
 | 
					function getCSRFToken () {
 | 
				
			||||||
  const name = 'csrf_token=';
 | 
					  const name = 'csrf_token='
 | 
				
			||||||
  const cookies = document.cookie.split(';');
 | 
					  const cookies = document.cookie.split(';')
 | 
				
			||||||
  for (let i = 0; i < cookies.length; i++) {
 | 
					  for (let i = 0; i < cookies.length; i++) {
 | 
				
			||||||
    let c = cookies[i].trim();
 | 
					    let c = cookies[i].trim()
 | 
				
			||||||
    if (c.indexOf(name) === 0) {
 | 
					    if (c.indexOf(name) === 0) {
 | 
				
			||||||
      return c.substring(name.length, c.length);
 | 
					      return c.substring(name.length, c.length)
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  return '';
 | 
					  return ''
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Request interceptor.
 | 
					// 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.
 | 
					  // 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']) {
 | 
					  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)
 | 
					    request.data = qs.stringify(request.data)
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
  return request
 | 
					  return request
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const getCustomAttributes = (appliesTo) => http.get('/api/v1/custom-attributes', {
 | 
					const getCustomAttributes = (appliesTo) =>
 | 
				
			||||||
  params: { applies_to: appliesTo }
 | 
					  http.get('/api/v1/custom-attributes', {
 | 
				
			||||||
})
 | 
					    params: { applies_to: appliesTo }
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
const createCustomAttribute = (data) =>
 | 
					const createCustomAttribute = (data) =>
 | 
				
			||||||
  http.post('/api/v1/custom-attributes', data, {
 | 
					  http.post('/api/v1/custom-attributes', data, {
 | 
				
			||||||
    headers: {
 | 
					    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 searchMessages = (params) => http.get('/api/v1/messages/search', { params })
 | 
				
			||||||
const searchContacts = (params) => http.get('/api/v1/contacts/search', { params })
 | 
					const searchContacts = (params) => http.get('/api/v1/contacts/search', { params })
 | 
				
			||||||
const getEmailNotificationSettings = () => http.get('/api/v1/settings/notifications/email')
 | 
					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 getPriorities = () => http.get('/api/v1/priorities')
 | 
				
			||||||
const getStatuses = () => http.get('/api/v1/statuses')
 | 
					const getStatuses = () => http.get('/api/v1/statuses')
 | 
				
			||||||
const createStatus = (data) => http.post('/api/v1/statuses', data)
 | 
					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 getAllBusinessHours = () => http.get('/api/v1/business-hours')
 | 
				
			||||||
const getBusinessHours = (id) => http.get(`/api/v1/business-hours/${id}`)
 | 
					const getBusinessHours = (id) => http.get(`/api/v1/business-hours/${id}`)
 | 
				
			||||||
const createBusinessHours = (data) => http.post('/api/v1/business-hours', data, {
 | 
					const createBusinessHours = (data) =>
 | 
				
			||||||
  headers: {
 | 
					  http.post('/api/v1/business-hours', data, {
 | 
				
			||||||
    'Content-Type': 'application/json'
 | 
					    headers: {
 | 
				
			||||||
  }
 | 
					      'Content-Type': 'application/json'
 | 
				
			||||||
})
 | 
					    }
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
const updateBusinessHours = (id, data) =>
 | 
					const updateBusinessHours = (id, data) =>
 | 
				
			||||||
  http.put(`/api/v1/business-hours/${id}`, data, {
 | 
					  http.put(`/api/v1/business-hours/${id}`, data, {
 | 
				
			||||||
    headers: {
 | 
					    headers: {
 | 
				
			||||||
@@ -96,16 +103,18 @@ const deleteBusinessHours = (id) => http.delete(`/api/v1/business-hours/${id}`)
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
const getAllSLAs = () => http.get('/api/v1/sla')
 | 
					const getAllSLAs = () => http.get('/api/v1/sla')
 | 
				
			||||||
const getSLA = (id) => http.get(`/api/v1/sla/${id}`)
 | 
					const getSLA = (id) => http.get(`/api/v1/sla/${id}`)
 | 
				
			||||||
const createSLA = (data) => http.post('/api/v1/sla', data, {
 | 
					const createSLA = (data) =>
 | 
				
			||||||
  headers: {
 | 
					  http.post('/api/v1/sla', data, {
 | 
				
			||||||
    'Content-Type': 'application/json'
 | 
					    headers: {
 | 
				
			||||||
  }
 | 
					      'Content-Type': 'application/json'
 | 
				
			||||||
})
 | 
					    }
 | 
				
			||||||
const updateSLA = (id, data) => http.put(`/api/v1/sla/${id}`, data, {
 | 
					  })
 | 
				
			||||||
  headers: {
 | 
					const updateSLA = (id, data) =>
 | 
				
			||||||
    'Content-Type': 'application/json'
 | 
					  http.put(`/api/v1/sla/${id}`, data, {
 | 
				
			||||||
  }
 | 
					    headers: {
 | 
				
			||||||
})
 | 
					      'Content-Type': 'application/json'
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
const deleteSLA = (id) => http.delete(`/api/v1/sla/${id}`)
 | 
					const deleteSLA = (id) => http.delete(`/api/v1/sla/${id}`)
 | 
				
			||||||
const createOIDC = (data) =>
 | 
					const createOIDC = (data) =>
 | 
				
			||||||
  http.post('/api/v1/oidc', data, {
 | 
					  http.post('/api/v1/oidc', data, {
 | 
				
			||||||
@@ -113,8 +122,7 @@ const createOIDC = (data) =>
 | 
				
			|||||||
      'Content-Type': 'application/json'
 | 
					      'Content-Type': 'application/json'
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  })
 | 
					  })
 | 
				
			||||||
const testOIDC = (data) => http.post('/api/v1/oidc/test', data)
 | 
					const getConfig = () => http.get('/api/v1/config')
 | 
				
			||||||
const getAllEnabledOIDC = () => http.get('/api/v1/oidc/enabled')
 | 
					 | 
				
			||||||
const getAllOIDC = () => http.get('/api/v1/oidc')
 | 
					const getAllOIDC = () => http.get('/api/v1/oidc')
 | 
				
			||||||
const getOIDC = (id) => http.get(`/api/v1/oidc/${id}`)
 | 
					const getOIDC = (id) => http.get(`/api/v1/oidc/${id}`)
 | 
				
			||||||
const updateOIDC = (id, data) =>
 | 
					const updateOIDC = (id, data) =>
 | 
				
			||||||
@@ -131,7 +139,11 @@ const updateSettings = (key, data) =>
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
  })
 | 
					  })
 | 
				
			||||||
const getSettings = (key) => http.get(`/api/v1/settings/${key}`)
 | 
					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) =>
 | 
					const getAutomationRules = (type) =>
 | 
				
			||||||
  http.get(`/api/v1/automations/rules`, {
 | 
					  http.get(`/api/v1/automations/rules`, {
 | 
				
			||||||
    params: { type: type }
 | 
					    params: { type: type }
 | 
				
			||||||
@@ -157,7 +169,12 @@ const updateAutomationRuleWeights = (data) =>
 | 
				
			|||||||
      'Content-Type': 'application/json'
 | 
					      '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 getRoles = () => http.get('/api/v1/roles')
 | 
				
			||||||
const getRole = (id) => http.get(`/api/v1/roles/${id}`)
 | 
					const getRole = (id) => http.get(`/api/v1/roles/${id}`)
 | 
				
			||||||
const createRole = (data) =>
 | 
					const createRole = (data) =>
 | 
				
			||||||
@@ -175,16 +192,29 @@ const updateRole = (id, data) =>
 | 
				
			|||||||
const deleteRole = (id) => http.delete(`/api/v1/roles/${id}`)
 | 
					const deleteRole = (id) => http.delete(`/api/v1/roles/${id}`)
 | 
				
			||||||
const getContacts = (params) => http.get('/api/v1/contacts', { params })
 | 
					const getContacts = (params) => http.get('/api/v1/contacts', { params })
 | 
				
			||||||
const getContact = (id) => http.get(`/api/v1/contacts/${id}`)
 | 
					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: {
 | 
					  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 getTeam = (id) => http.get(`/api/v1/teams/${id}`)
 | 
				
			||||||
const getTeams = () => http.get('/api/v1/teams')
 | 
					const getTeams = () => http.get('/api/v1/teams')
 | 
				
			||||||
const updateTeam = (id, data) => http.put(`/api/v1/teams/${id}`, data)
 | 
					const updateTeam = (id, data) => http.put(`/api/v1/teams/${id}`, data, {
 | 
				
			||||||
const createTeam = (data) => http.post('/api/v1/teams', 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 getTeamsCompact = () => http.get('/api/v1/teams/compact')
 | 
				
			||||||
const deleteTeam = (id) => http.delete(`/api/v1/teams/${id}`)
 | 
					const deleteTeam = (id) => http.delete(`/api/v1/teams/${id}`)
 | 
				
			||||||
const updateUser = (id, data) =>
 | 
					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 deleteUserAvatar = () => http.delete('/api/v1/agents/me/avatar')
 | 
				
			||||||
const getCurrentUser = () => http.get('/api/v1/agents/me')
 | 
					const getCurrentUser = () => http.get('/api/v1/agents/me')
 | 
				
			||||||
const getCurrentUserTeams = () => http.get('/api/v1/agents/me/teams')
 | 
					const getCurrentUserTeams = () => http.get('/api/v1/agents/me/teams')
 | 
				
			||||||
const updateCurrentUserAvailability = (data) => http.put('/api/v1/agents/me/availability', data)
 | 
					const updateCurrentUserAvailability = (data) => http.put('/api/v1/agents/me/availability', data, {
 | 
				
			||||||
const resetPassword = (data) => http.post('/api/v1/agents/reset-password', data)
 | 
					  headers: {
 | 
				
			||||||
const setPassword = (data) => http.post('/api/v1/agents/set-password', data)
 | 
					    '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 deleteUser = (id) => http.delete(`/api/v1/agents/${id}`)
 | 
				
			||||||
const createUser = (data) =>
 | 
					const createUser = (data) =>
 | 
				
			||||||
  http.post('/api/v1/agents', data, {
 | 
					  http.post('/api/v1/agents', data, {
 | 
				
			||||||
@@ -216,28 +258,56 @@ const createUser = (data) =>
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
  })
 | 
					  })
 | 
				
			||||||
const getTags = () => http.get('/api/v1/tags')
 | 
					const getTags = () => http.get('/api/v1/tags')
 | 
				
			||||||
const upsertTags = (uuid, data) => http.post(`/api/v1/conversations/${uuid}/tags`, data)
 | 
					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)
 | 
					  headers: {
 | 
				
			||||||
const removeAssignee = (uuid, assignee_type) => http.put(`/api/v1/conversations/${uuid}/assignee/${assignee_type}/remove`)
 | 
					    'Content-Type': 'application/json'
 | 
				
			||||||
const updateContactCustomAttribute = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/contacts/custom-attributes`, data,
 | 
					  }
 | 
				
			||||||
  {
 | 
					})
 | 
				
			||||||
 | 
					const updateAssignee = (uuid, assignee_type, data) =>
 | 
				
			||||||
 | 
					  http.put(`/api/v1/conversations/${uuid}/assignee/${assignee_type}`, data, {
 | 
				
			||||||
    headers: {
 | 
					    headers: {
 | 
				
			||||||
      'Content-Type': 'application/json'
 | 
					      '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: {
 | 
					    headers: {
 | 
				
			||||||
      'Content-Type': 'application/json'
 | 
					      '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 updateAssigneeLastSeen = (uuid) => http.put(`/api/v1/conversations/${uuid}/last-seen`)
 | 
				
			||||||
const getConversationMessage = (cuuid, uuid) => http.get(`/api/v1/conversations/${cuuid}/messages/${uuid}`)
 | 
					const getConversationMessage = (cuuid, uuid) =>
 | 
				
			||||||
const retryMessage = (cuuid, uuid) => http.put(`/api/v1/conversations/${cuuid}/messages/${uuid}/retry`)
 | 
					  http.get(`/api/v1/conversations/${cuuid}/messages/${uuid}`)
 | 
				
			||||||
const getConversationMessages = (uuid, params) => http.get(`/api/v1/conversations/${uuid}/messages`, { params })
 | 
					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) =>
 | 
					const sendMessage = (uuid, data) =>
 | 
				
			||||||
  http.post(`/api/v1/conversations/${uuid}/messages`, data, {
 | 
					  http.post(`/api/v1/conversations/${uuid}/messages`, data, {
 | 
				
			||||||
    headers: {
 | 
					    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 getConversationParticipants = (uuid) => http.get(`/api/v1/conversations/${uuid}/participants`)
 | 
				
			||||||
const getAllMacros = () => http.get('/api/v1/macros')
 | 
					const getAllMacros = () => http.get('/api/v1/macros')
 | 
				
			||||||
const getMacro = (id) => http.get(`/api/v1/macros/${id}`)
 | 
					const getMacro = (id) => http.get(`/api/v1/macros/${id}`)
 | 
				
			||||||
const createMacro = (data) => http.post('/api/v1/macros', data, {
 | 
					const createMacro = (data) =>
 | 
				
			||||||
  headers: {
 | 
					  http.post('/api/v1/macros', data, {
 | 
				
			||||||
    'Content-Type': 'application/json'
 | 
					    headers: {
 | 
				
			||||||
  }
 | 
					      'Content-Type': 'application/json'
 | 
				
			||||||
})
 | 
					    }
 | 
				
			||||||
const updateMacro = (id, data) => http.put(`/api/v1/macros/${id}`, data, {
 | 
					  })
 | 
				
			||||||
  headers: {
 | 
					const updateMacro = (id, data) =>
 | 
				
			||||||
    'Content-Type': 'application/json'
 | 
					  http.put(`/api/v1/macros/${id}`, data, {
 | 
				
			||||||
  }
 | 
					    headers: {
 | 
				
			||||||
})
 | 
					      'Content-Type': 'application/json'
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
const deleteMacro = (id) => http.delete(`/api/v1/macros/${id}`)
 | 
					const deleteMacro = (id) => http.delete(`/api/v1/macros/${id}`)
 | 
				
			||||||
const applyMacro = (uuid, id, data) => http.post(`/api/v1/conversations/${uuid}/macros/${id}/apply`, data, {
 | 
					const applyMacro = (uuid, id, data) =>
 | 
				
			||||||
  headers: {
 | 
					  http.post(`/api/v1/conversations/${uuid}/macros/${id}/apply`, data, {
 | 
				
			||||||
    'Content-Type': 'application/json'
 | 
					    headers: {
 | 
				
			||||||
  }
 | 
					      'Content-Type': 'application/json'
 | 
				
			||||||
})
 | 
					    }
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
const getTeamUnassignedConversations = (teamID, params) =>
 | 
					const getTeamUnassignedConversations = (teamID, params) =>
 | 
				
			||||||
  http.get(`/api/v1/teams/${teamID}/conversations/unassigned`, { params })
 | 
					  http.get(`/api/v1/teams/${teamID}/conversations/unassigned`, { params })
 | 
				
			||||||
const getAssignedConversations = (params) => http.get('/api/v1/conversations/assigned', { 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 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) =>
 | 
					const uploadMedia = (data) =>
 | 
				
			||||||
  http.post('/api/v1/media', data, {
 | 
					  http.post('/api/v1/media', data, {
 | 
				
			||||||
    headers: {
 | 
					    headers: {
 | 
				
			||||||
@@ -277,7 +352,8 @@ const uploadMedia = (data) =>
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
  })
 | 
					  })
 | 
				
			||||||
const getOverviewCounts = () => http.get('/api/v1/reports/overview/counts')
 | 
					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 getLanguage = (lang) => http.get(`/api/v1/lang/${lang}`)
 | 
				
			||||||
const createInbox = (data) =>
 | 
					const createInbox = (data) =>
 | 
				
			||||||
  http.post('/api/v1/inboxes', 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 deleteView = (id) => http.delete(`/api/v1/views/me/${id}`)
 | 
				
			||||||
const getAiPrompts = () => http.get('/api/v1/ai/prompts')
 | 
					const getAiPrompts = () => http.get('/api/v1/ai/prompts')
 | 
				
			||||||
const aiCompletion = (data) => http.post('/api/v1/ai/completion', data)
 | 
					const aiCompletion = (data) => http.post('/api/v1/ai/completion', data, {
 | 
				
			||||||
const updateAIProvider = (data) => http.put('/api/v1/ai/provider', 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 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 deleteContactNote = (id, noteId) => http.delete(`/api/v1/contacts/${id}/notes/${noteId}`)
 | 
				
			||||||
const getActivityLogs = (params) => http.get('/api/v1/activity-logs', { params })
 | 
					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 {
 | 
					export default {
 | 
				
			||||||
  login,
 | 
					  login,
 | 
				
			||||||
@@ -356,6 +470,7 @@ export default {
 | 
				
			|||||||
  getViewConversations,
 | 
					  getViewConversations,
 | 
				
			||||||
  getOverviewCharts,
 | 
					  getOverviewCharts,
 | 
				
			||||||
  getOverviewCounts,
 | 
					  getOverviewCounts,
 | 
				
			||||||
 | 
					  getOverviewSLA,
 | 
				
			||||||
  getConversationParticipants,
 | 
					  getConversationParticipants,
 | 
				
			||||||
  getConversationMessage,
 | 
					  getConversationMessage,
 | 
				
			||||||
  getConversationMessages,
 | 
					  getConversationMessages,
 | 
				
			||||||
@@ -399,10 +514,9 @@ export default {
 | 
				
			|||||||
  updateSettings,
 | 
					  updateSettings,
 | 
				
			||||||
  createOIDC,
 | 
					  createOIDC,
 | 
				
			||||||
  getAllOIDC,
 | 
					  getAllOIDC,
 | 
				
			||||||
  getAllEnabledOIDC,
 | 
					  getConfig,
 | 
				
			||||||
  getOIDC,
 | 
					  getOIDC,
 | 
				
			||||||
  updateOIDC,
 | 
					  updateOIDC,
 | 
				
			||||||
  testOIDC,
 | 
					 | 
				
			||||||
  deleteOIDC,
 | 
					  deleteOIDC,
 | 
				
			||||||
  getTemplate,
 | 
					  getTemplate,
 | 
				
			||||||
  getTemplates,
 | 
					  getTemplates,
 | 
				
			||||||
@@ -444,5 +558,14 @@ export default {
 | 
				
			|||||||
  getContactNotes,
 | 
					  getContactNotes,
 | 
				
			||||||
  createContactNote,
 | 
					  createContactNote,
 | 
				
			||||||
  deleteContactNote,
 | 
					  deleteContactNote,
 | 
				
			||||||
  getActivityLogs
 | 
					  getActivityLogs,
 | 
				
			||||||
 | 
					  getWebhooks,
 | 
				
			||||||
 | 
					  getWebhook,
 | 
				
			||||||
 | 
					  createWebhook,
 | 
				
			||||||
 | 
					  updateWebhook,
 | 
				
			||||||
 | 
					  deleteWebhook,
 | 
				
			||||||
 | 
					  toggleWebhook,
 | 
				
			||||||
 | 
					  testWebhook,
 | 
				
			||||||
 | 
					  generateAPIKey,
 | 
				
			||||||
 | 
					  revokeAPIKey
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -13,12 +13,20 @@
 | 
				
			|||||||
    min-height: 100%;
 | 
					    min-height: 100%;
 | 
				
			||||||
    overflow: hidden;
 | 
					    overflow: hidden;
 | 
				
			||||||
    margin: 0;
 | 
					    margin: 0;
 | 
				
			||||||
 | 
					    @apply bg-background text-foreground;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @media (max-width: 768px) {
 | 
					  @media (max-width: 768px) {
 | 
				
			||||||
 | 
					    html,
 | 
				
			||||||
 | 
					    body {
 | 
				
			||||||
      overflow-x: auto;
 | 
					      overflow-x: auto;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  * {
 | 
				
			||||||
 | 
					    @apply border-border;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  .native-html {
 | 
					  .native-html {
 | 
				
			||||||
    p {
 | 
					    p {
 | 
				
			||||||
      margin-bottom: 0.5rem;
 | 
					      margin-bottom: 0.5rem;
 | 
				
			||||||
@@ -129,10 +137,10 @@
 | 
				
			|||||||
    --background: 240 5.9% 10%;
 | 
					    --background: 240 5.9% 10%;
 | 
				
			||||||
    --foreground: 0 0% 98%;
 | 
					    --foreground: 0 0% 98%;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    --card: 240 10% 3.9%;
 | 
					    --card: 240 5.9% 10%;
 | 
				
			||||||
    --card-foreground: 0 0% 98%;
 | 
					    --card-foreground: 0 0% 98%;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    --popover: 240 10% 3.9%;
 | 
					    --popover: 240 5.9% 10%;
 | 
				
			||||||
    --popover-foreground: 0 0% 98%;
 | 
					    --popover-foreground: 0 0% 98%;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    --primary: 0 0% 98%;
 | 
					    --primary: 0 0% 98%;
 | 
				
			||||||
@@ -176,6 +184,10 @@
 | 
				
			|||||||
  @apply border shadow rounded;
 | 
					  @apply border shadow rounded;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.loading-fade {
 | 
				
			||||||
 | 
					  @apply opacity-50 transition-opacity duration-300
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Scrollbar start
 | 
					// Scrollbar start
 | 
				
			||||||
::-webkit-scrollbar {
 | 
					::-webkit-scrollbar {
 | 
				
			||||||
  width: 8px; /* Adjust width */
 | 
					  width: 8px; /* Adjust width */
 | 
				
			||||||
@@ -199,10 +211,6 @@
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
// End Scrollbar
 | 
					// End Scrollbar
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.code-editor {
 | 
					 | 
				
			||||||
  @apply rounded border shadow h-[65vh] min-h-[250px] w-full relative;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.show-quoted-text {
 | 
					.show-quoted-text {
 | 
				
			||||||
  blockquote {
 | 
					  blockquote {
 | 
				
			||||||
    @apply block;
 | 
					    @apply block;
 | 
				
			||||||
@@ -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] {
 | 
					[data-radix-popper-content-wrapper] {
 | 
				
			||||||
  z-index: 9999 !important;
 | 
					  z-index: 9999 !important;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -256,12 +233,3 @@
 | 
				
			|||||||
    @apply text-blue-500 hover:underline;
 | 
					    @apply text-blue-500 hover:underline;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					 | 
				
			||||||
@layer base {
 | 
					 | 
				
			||||||
  * {
 | 
					 | 
				
			||||||
    @apply border-border;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  body {
 | 
					 | 
				
			||||||
    @apply bg-background text-foreground;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										63
									
								
								frontend/src/components/banner/AdminBanner.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								frontend/src/components/banner/AdminBanner.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,63 @@
 | 
				
			|||||||
 | 
					<template>
 | 
				
			||||||
 | 
					  <div class="border-b">
 | 
				
			||||||
 | 
					    <!-- Update notification -->
 | 
				
			||||||
 | 
					    <div
 | 
				
			||||||
 | 
					      v-if="appSettingsStore.settings['app.update']?.update?.is_new"
 | 
				
			||||||
 | 
					      class="px-4 py-2.5 border-b border-border/50 last:border-b-0"
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      <div class="flex items-center gap-3">
 | 
				
			||||||
 | 
					        <div class="flex-shrink-0">
 | 
				
			||||||
 | 
					          <Download class="w-5 h-5 text-primary" />
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        <div class="min-w-0 flex-1">
 | 
				
			||||||
 | 
					          <div class="flex items-center gap-2 text-sm text-foreground">
 | 
				
			||||||
 | 
					            <span>{{ $t('update.newUpdateAvailable') }}</span>
 | 
				
			||||||
 | 
					            <a
 | 
				
			||||||
 | 
					              :href="appSettingsStore.settings['app.update'].update.url"
 | 
				
			||||||
 | 
					              target="_blank"
 | 
				
			||||||
 | 
					              rel="nofollow noreferrer"
 | 
				
			||||||
 | 
					              class="font-semibold text-primary hover:text-primary/80 underline transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1"
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					              {{ appSettingsStore.settings['app.update'].update.release_version }}
 | 
				
			||||||
 | 
					            </a>
 | 
				
			||||||
 | 
					            <span class="text-muted-foreground">•</span>
 | 
				
			||||||
 | 
					            <span class="text-muted-foreground">
 | 
				
			||||||
 | 
					              {{ appSettingsStore.settings['app.update'].update.release_date }}
 | 
				
			||||||
 | 
					            </span>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          <!-- Update description -->
 | 
				
			||||||
 | 
					          <div
 | 
				
			||||||
 | 
					            v-if="appSettingsStore.settings['app.update'].update.description"
 | 
				
			||||||
 | 
					            class="mt-2 text-xs text-muted-foreground"
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            {{ appSettingsStore.settings['app.update'].update.description }}
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <!-- Restart required notification -->
 | 
				
			||||||
 | 
					    <div
 | 
				
			||||||
 | 
					      v-if="appSettingsStore.settings['app.restart_required']"
 | 
				
			||||||
 | 
					      class="px-4 py-2.5 border-b border-border/50 last:border-b-0"
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      <div class="flex items-center gap-3">
 | 
				
			||||||
 | 
					        <div class="flex-shrink-0">
 | 
				
			||||||
 | 
					          <Info class="w-5 h-5 text-primary" />
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        <div class="min-w-0 flex-1">
 | 
				
			||||||
 | 
					          <div class="text-sm text-foreground">
 | 
				
			||||||
 | 
					            {{ $t('admin.banner.restartMessage') }}
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<script setup>
 | 
				
			||||||
 | 
					import { Download, Info } from 'lucide-vue-next'
 | 
				
			||||||
 | 
					import { useAppSettingsStore } from '@/stores/appSettings'
 | 
				
			||||||
 | 
					const appSettingsStore = useAppSettingsStore()
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
							
								
								
									
										24
									
								
								frontend/src/components/button/CloseButton.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								frontend/src/components/button/CloseButton.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,24 @@
 | 
				
			|||||||
 | 
					<template>
 | 
				
			||||||
 | 
					  <Button
 | 
				
			||||||
 | 
					    variant="ghost"
 | 
				
			||||||
 | 
					    @click.stop="onClose"
 | 
				
			||||||
 | 
					    size="xs"
 | 
				
			||||||
 | 
					    class="text-gray-400 dark:text-gray-500 hover:text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800 w-6 h-6 p-0"
 | 
				
			||||||
 | 
					  >
 | 
				
			||||||
 | 
					    <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>
 | 
				
			||||||
							
								
								
									
										61
									
								
								frontend/src/components/combobox/SelectCombobox.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								frontend/src/components/combobox/SelectCombobox.vue
									
									
									
									
									
										Normal 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>
 | 
				
			||||||
@@ -1,10 +1,13 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
    <div ref="codeEditor" id="code-editor" class="code-editor" />
 | 
					    <div ref="codeEditor" @click="editorView?.focus()" class="w-full h-[28rem] border rounded-md" />
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { ref, onMounted, watch, nextTick } from 'vue'
 | 
					import { ref, onMounted, watch, nextTick, useTemplateRef } from 'vue'
 | 
				
			||||||
import CodeFlask from 'codeflask'
 | 
					import { EditorView, basicSetup } from 'codemirror'
 | 
				
			||||||
 | 
					import { html } from '@codemirror/lang-html'
 | 
				
			||||||
 | 
					import { oneDark } from '@codemirror/theme-one-dark'
 | 
				
			||||||
 | 
					import { useColorMode } from '@vueuse/core'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = defineProps({
 | 
					const props = defineProps({
 | 
				
			||||||
    modelValue: { type: String, default: '' },
 | 
					    modelValue: { type: String, default: '' },
 | 
				
			||||||
@@ -13,45 +16,38 @@ const props = defineProps({
 | 
				
			|||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const emit = defineEmits(['update:modelValue'])
 | 
					const emit = defineEmits(['update:modelValue'])
 | 
				
			||||||
const codeEditor = ref(null)
 | 
					 | 
				
			||||||
const data = ref('')
 | 
					const data = ref('')
 | 
				
			||||||
const flask = ref(null)
 | 
					let editorView = null 
 | 
				
			||||||
 | 
					const codeEditor = useTemplateRef('codeEditor')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const initCodeEditor = (body) => {
 | 
					const initCodeEditor = (body) => {
 | 
				
			||||||
    const el = document.createElement('code-flask')
 | 
					    const isDark = useColorMode().value === 'dark'
 | 
				
			||||||
    el.attachShadow({ mode: 'open' })
 | 
					 | 
				
			||||||
    el.shadowRoot.innerHTML = `
 | 
					 | 
				
			||||||
      <style>
 | 
					 | 
				
			||||||
        .codeflask .codeflask__flatten {
 | 
					 | 
				
			||||||
          font-size: 15px;
 | 
					 | 
				
			||||||
          white-space: pre-wrap;
 | 
					 | 
				
			||||||
          word-break: break-word;
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        .codeflask .codeflask__lines { background: #fafafa; z-index: 10; }
 | 
					 | 
				
			||||||
        .codeflask .token.tag { font-weight: bold; }
 | 
					 | 
				
			||||||
        .codeflask .token.attr-name { color: #111; }
 | 
					 | 
				
			||||||
        .codeflask .token.attr-value { color: #000 !important; }
 | 
					 | 
				
			||||||
      </style>
 | 
					 | 
				
			||||||
      <div id="area"></div>
 | 
					 | 
				
			||||||
    `
 | 
					 | 
				
			||||||
    codeEditor.value.appendChild(el)
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    flask.value = new CodeFlask(el.shadowRoot.getElementById('area'), {
 | 
					    editorView = new EditorView({
 | 
				
			||||||
        language: props.language,
 | 
					        doc: body,
 | 
				
			||||||
        lineNumbers: false,
 | 
					        extensions: [
 | 
				
			||||||
        styleParent: el.shadowRoot,
 | 
					            basicSetup,
 | 
				
			||||||
        readonly: props.disabled
 | 
					            html(),
 | 
				
			||||||
 | 
					            ...(isDark ? [oneDark] : []),
 | 
				
			||||||
 | 
					            EditorView.editable.of(!props.disabled),
 | 
				
			||||||
 | 
					            EditorView.theme({
 | 
				
			||||||
 | 
					                '&': { height: '100%' },
 | 
				
			||||||
 | 
					                '.cm-editor': { height: '100%' },
 | 
				
			||||||
 | 
					                '.cm-scroller': { overflow: 'auto' }
 | 
				
			||||||
 | 
					            }),
 | 
				
			||||||
 | 
					            EditorView.updateListener.of((update) => {
 | 
				
			||||||
 | 
					                if (!update.docChanged) return
 | 
				
			||||||
 | 
					                const v = update.state.doc.toString()
 | 
				
			||||||
 | 
					                emit('update:modelValue', v)
 | 
				
			||||||
 | 
					                data.value = v
 | 
				
			||||||
 | 
					                
 | 
				
			||||||
 | 
					            })
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					        parent: codeEditor.value
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    flask.value.onUpdate((v) => {
 | 
					 | 
				
			||||||
        emit('update:modelValue', v)
 | 
					 | 
				
			||||||
        data.value = v
 | 
					 | 
				
			||||||
    })
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    flask.value.updateCode(body)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    nextTick(() => {
 | 
					    nextTick(() => {
 | 
				
			||||||
        document.querySelector('code-flask').shadowRoot.querySelector('textarea').focus()
 | 
					        editorView?.focus()
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -61,7 +57,9 @@ onMounted(() => {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
watch(() => props.modelValue, (newVal) => {
 | 
					watch(() => props.modelValue, (newVal) => {
 | 
				
			||||||
    if (newVal !== data.value) {
 | 
					    if (newVal !== data.value) {
 | 
				
			||||||
        flask.value.updateCode(newVal)
 | 
					        editorView?.dispatch({
 | 
				
			||||||
 | 
					            changes: { from: 0, to: editorView.state.doc.length, insert: newVal }
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
@@ -30,25 +30,23 @@
 | 
				
			|||||||
        <Button
 | 
					        <Button
 | 
				
			||||||
          size="sm"
 | 
					          size="sm"
 | 
				
			||||||
          variant="ghost"
 | 
					          variant="ghost"
 | 
				
			||||||
          @click.prevent="isBold = !isBold"
 | 
					          @click.prevent="editor?.chain().focus().toggleBold().run()"
 | 
				
			||||||
          :active="isBold"
 | 
					          :class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('bold') }"
 | 
				
			||||||
          :class="{ 'bg-gray-200 dark:bg-secondary': isBold }"
 | 
					 | 
				
			||||||
        >
 | 
					        >
 | 
				
			||||||
          <Bold size="14" />
 | 
					          <Bold size="14" />
 | 
				
			||||||
        </Button>
 | 
					        </Button>
 | 
				
			||||||
        <Button
 | 
					        <Button
 | 
				
			||||||
          size="sm"
 | 
					          size="sm"
 | 
				
			||||||
          variant="ghost"
 | 
					          variant="ghost"
 | 
				
			||||||
          @click.prevent="isItalic = !isItalic"
 | 
					          @click.prevent="editor?.chain().focus().toggleItalic().run()"
 | 
				
			||||||
          :active="isItalic"
 | 
					          :class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('italic') }"
 | 
				
			||||||
          :class="{ 'bg-gray-200 dark:bg-secondary': isItalic }"
 | 
					 | 
				
			||||||
        >
 | 
					        >
 | 
				
			||||||
          <Italic size="14" />
 | 
					          <Italic size="14" />
 | 
				
			||||||
        </Button>
 | 
					        </Button>
 | 
				
			||||||
        <Button
 | 
					        <Button
 | 
				
			||||||
          size="sm"
 | 
					          size="sm"
 | 
				
			||||||
          variant="ghost"
 | 
					          variant="ghost"
 | 
				
			||||||
          @click.prevent="toggleBulletList"
 | 
					          @click.prevent="editor?.chain().focus().toggleBulletList().run()"
 | 
				
			||||||
          :class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('bulletList') }"
 | 
					          :class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('bulletList') }"
 | 
				
			||||||
        >
 | 
					        >
 | 
				
			||||||
          <List size="14" />
 | 
					          <List size="14" />
 | 
				
			||||||
@@ -57,7 +55,7 @@
 | 
				
			|||||||
        <Button
 | 
					        <Button
 | 
				
			||||||
          size="sm"
 | 
					          size="sm"
 | 
				
			||||||
          variant="ghost"
 | 
					          variant="ghost"
 | 
				
			||||||
          @click.prevent="toggleOrderedList"
 | 
					          @click.prevent="editor?.chain().focus().toggleOrderedList().run()"
 | 
				
			||||||
          :class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('orderedList') }"
 | 
					          :class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('orderedList') }"
 | 
				
			||||||
        >
 | 
					        >
 | 
				
			||||||
          <ListOrdered size="14" />
 | 
					          <ListOrdered size="14" />
 | 
				
			||||||
@@ -91,7 +89,7 @@
 | 
				
			|||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { ref, watch, watchEffect, onUnmounted } from 'vue'
 | 
					import { ref, watch, onUnmounted } from 'vue'
 | 
				
			||||||
import { useEditor, EditorContent, BubbleMenu } from '@tiptap/vue-3'
 | 
					import { useEditor, EditorContent, BubbleMenu } from '@tiptap/vue-3'
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  ChevronDown,
 | 
					  ChevronDown,
 | 
				
			||||||
@@ -121,21 +119,18 @@ import TableRow from '@tiptap/extension-table-row'
 | 
				
			|||||||
import TableCell from '@tiptap/extension-table-cell'
 | 
					import TableCell from '@tiptap/extension-table-cell'
 | 
				
			||||||
import TableHeader from '@tiptap/extension-table-header'
 | 
					import TableHeader from '@tiptap/extension-table-header'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const selectedText = defineModel('selectedText', { default: '' })
 | 
					const textContent = defineModel('textContent', { default: '' })
 | 
				
			||||||
const textContent = defineModel('textContent')
 | 
					const htmlContent = defineModel('htmlContent', { default: '' })
 | 
				
			||||||
const htmlContent = defineModel('htmlContent')
 | 
					 | 
				
			||||||
const isBold = defineModel('isBold')
 | 
					 | 
				
			||||||
const isItalic = defineModel('isItalic')
 | 
					 | 
				
			||||||
const cursorPosition = defineModel('cursorPosition', { default: 0 })
 | 
					 | 
				
			||||||
const showLinkInput = ref(false)
 | 
					const showLinkInput = ref(false)
 | 
				
			||||||
const linkUrl = ref('')
 | 
					const linkUrl = ref('')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = defineProps({
 | 
					const props = defineProps({
 | 
				
			||||||
  placeholder: String,
 | 
					  placeholder: String,
 | 
				
			||||||
  contentToSet: String,
 | 
					 | 
				
			||||||
  setInlineImage: Object,
 | 
					 | 
				
			||||||
  insertContent: String,
 | 
					  insertContent: String,
 | 
				
			||||||
  clearContent: Boolean,
 | 
					  autoFocus: {
 | 
				
			||||||
 | 
					    type: Boolean,
 | 
				
			||||||
 | 
					    default: true
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
  aiPrompts: {
 | 
					  aiPrompts: {
 | 
				
			||||||
    type: Array,
 | 
					    type: Array,
 | 
				
			||||||
    default: () => []
 | 
					    default: () => []
 | 
				
			||||||
@@ -146,8 +141,6 @@ const emit = defineEmits(['send', 'aiPromptSelected'])
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
const emitPrompt = (key) => emit('aiPromptSelected', key)
 | 
					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.
 | 
					// To preseve the table styling in emails, need to set the table style inline.
 | 
				
			||||||
// Created these custom extensions to set the table style inline.
 | 
					// Created these custom extensions to set the table style inline.
 | 
				
			||||||
const CustomTable = Table.extend({
 | 
					const CustomTable = Table.extend({
 | 
				
			||||||
@@ -156,7 +149,7 @@ const CustomTable = Table.extend({
 | 
				
			|||||||
      ...this.parent?.(),
 | 
					      ...this.parent?.(),
 | 
				
			||||||
      style: {
 | 
					      style: {
 | 
				
			||||||
        parseHTML: (element) =>
 | 
					        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: {
 | 
					      style: {
 | 
				
			||||||
        parseHTML: (element) =>
 | 
					        parseHTML: (element) =>
 | 
				
			||||||
          (element.getAttribute('style') || '') +
 | 
					          (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: {
 | 
					      style: {
 | 
				
			||||||
        parseHTML: (element) =>
 | 
					        parseHTML: (element) =>
 | 
				
			||||||
          (element.getAttribute('style') || '') +
 | 
					          (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: [
 | 
					  extensions: [
 | 
				
			||||||
    StarterKit.configure(),
 | 
					    StarterKit.configure(),
 | 
				
			||||||
    Image.configure({ HTMLAttributes: { class: 'inline-image' } }),
 | 
					    Image.configure({ HTMLAttributes: { class: 'inline-image' } }),
 | 
				
			||||||
    Placeholder.configure({ placeholder: () => props.placeholder }),
 | 
					    Placeholder.configure({ placeholder: () => props.placeholder }),
 | 
				
			||||||
    Link,
 | 
					    Link,
 | 
				
			||||||
    CustomTable.configure({
 | 
					    CustomTable.configure({ resizable: false }),
 | 
				
			||||||
      resizable: false
 | 
					 | 
				
			||||||
    }),
 | 
					 | 
				
			||||||
    TableRow,
 | 
					    TableRow,
 | 
				
			||||||
    CustomTableCell,
 | 
					    CustomTableCell,
 | 
				
			||||||
    CustomTableHeader
 | 
					    CustomTableHeader
 | 
				
			||||||
  ],
 | 
					  ],
 | 
				
			||||||
  autofocus: true,
 | 
					  autofocus: props.autoFocus,
 | 
				
			||||||
 | 
					  content: htmlContent.value,
 | 
				
			||||||
  editorProps: {
 | 
					  editorProps: {
 | 
				
			||||||
    attributes: { class: 'outline-none' },
 | 
					    attributes: { class: 'outline-none' },
 | 
				
			||||||
    handleKeyDown: (view, event) => {
 | 
					    handleKeyDown: (view, event) => {
 | 
				
			||||||
@@ -209,110 +203,30 @@ const editorConfig = {
 | 
				
			|||||||
        emit('send')
 | 
					        emit('send')
 | 
				
			||||||
        return true
 | 
					        return true
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
      if (event.ctrlKey && event.key.toLowerCase() === 'b') {
 | 
					 | 
				
			||||||
         // Prevent outer listeners
 | 
					 | 
				
			||||||
        event.stopPropagation()
 | 
					 | 
				
			||||||
        return false
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  },
 | 
				
			||||||
}
 | 
					  // To update state when user types.
 | 
				
			||||||
 | 
					  onUpdate: ({ editor }) => {
 | 
				
			||||||
const editor = ref(
 | 
					    isInternalUpdate.value = true
 | 
				
			||||||
  useEditor({
 | 
					    htmlContent.value = editor.getHTML()
 | 
				
			||||||
    ...editorConfig,
 | 
					    textContent.value = editor.getText()
 | 
				
			||||||
    content: htmlContent.value,
 | 
					    isInternalUpdate.value = false
 | 
				
			||||||
    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()
 | 
					 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
watch(
 | 
					watch(
 | 
				
			||||||
  () => props.contentToSet,
 | 
					  htmlContent,
 | 
				
			||||||
  (newContentData) => {
 | 
					  (newContent) => {
 | 
				
			||||||
    if (!newContentData) return
 | 
					    if (!isInternalUpdate.value && editor.value && newContent !== editor.value.getHTML()) {
 | 
				
			||||||
    try {
 | 
					      editor.value.commands.setContent(newContent || '', false)
 | 
				
			||||||
      const parsedData = JSON.parse(newContentData)
 | 
					      textContent.value = editor.value.getText()
 | 
				
			||||||
      const content = parsedData.content
 | 
					      editor.value.commands.focus()
 | 
				
			||||||
      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)
 | 
					 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  },
 | 
				
			||||||
)
 | 
					  { immediate: true }
 | 
				
			||||||
 | 
					 | 
				
			||||||
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
 | 
					 | 
				
			||||||
      })
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Insert content at cursor position when insertContent prop changes.
 | 
				
			||||||
watch(
 | 
					watch(
 | 
				
			||||||
  () => props.insertContent,
 | 
					  () => props.insertContent,
 | 
				
			||||||
  (val) => {
 | 
					  (val) => {
 | 
				
			||||||
@@ -324,18 +238,6 @@ onUnmounted(() => {
 | 
				
			|||||||
  editor.value?.destroy()
 | 
					  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 = () => {
 | 
					const openLinkModal = () => {
 | 
				
			||||||
  if (editor.value?.isActive('link')) {
 | 
					  if (editor.value?.isActive('link')) {
 | 
				
			||||||
    linkUrl.value = editor.value.getAttributes('link').href
 | 
					    linkUrl.value = editor.value.getAttributes('link').href
 | 
				
			||||||
@@ -11,8 +11,12 @@
 | 
				
			|||||||
        <!-- Field -->
 | 
					        <!-- Field -->
 | 
				
			||||||
        <div class="flex-1">
 | 
					        <div class="flex-1">
 | 
				
			||||||
          <Select v-model="modelFilter.field">
 | 
					          <Select v-model="modelFilter.field">
 | 
				
			||||||
            <SelectTrigger class="bg-transparent hover:bg-slate-100 w-full">
 | 
					            <SelectTrigger>
 | 
				
			||||||
              <SelectValue :placeholder="t('form.field.selectField')" />
 | 
					              <SelectValue
 | 
				
			||||||
 | 
					                :placeholder="
 | 
				
			||||||
 | 
					                  t('globals.messages.select', { name: t('globals.terms.field').toLowerCase() })
 | 
				
			||||||
 | 
					                "
 | 
				
			||||||
 | 
					              />
 | 
				
			||||||
            </SelectTrigger>
 | 
					            </SelectTrigger>
 | 
				
			||||||
            <SelectContent>
 | 
					            <SelectContent>
 | 
				
			||||||
              <SelectGroup>
 | 
					              <SelectGroup>
 | 
				
			||||||
@@ -27,8 +31,12 @@
 | 
				
			|||||||
        <!-- Operator -->
 | 
					        <!-- Operator -->
 | 
				
			||||||
        <div class="flex-1">
 | 
					        <div class="flex-1">
 | 
				
			||||||
          <Select v-model="modelFilter.operator" v-if="modelFilter.field">
 | 
					          <Select v-model="modelFilter.operator" v-if="modelFilter.field">
 | 
				
			||||||
            <SelectTrigger class="bg-transparent hover:bg-slate-100 w-full">
 | 
					            <SelectTrigger>
 | 
				
			||||||
              <SelectValue :placeholder="t('form.field.selectOperator')" />
 | 
					              <SelectValue
 | 
				
			||||||
 | 
					                :placeholder="
 | 
				
			||||||
 | 
					                  t('globals.messages.select', { name: t('globals.terms.operator').toLowerCase() })
 | 
				
			||||||
 | 
					                "
 | 
				
			||||||
 | 
					              />
 | 
				
			||||||
            </SelectTrigger>
 | 
					            </SelectTrigger>
 | 
				
			||||||
            <SelectContent>
 | 
					            <SelectContent>
 | 
				
			||||||
              <SelectGroup>
 | 
					              <SelectGroup>
 | 
				
			||||||
@@ -44,83 +52,58 @@
 | 
				
			|||||||
        <div class="flex-1">
 | 
					        <div class="flex-1">
 | 
				
			||||||
          <div v-if="modelFilter.field && modelFilter.operator">
 | 
					          <div v-if="modelFilter.field && modelFilter.operator">
 | 
				
			||||||
            <template v-if="modelFilter.operator !== 'set' && modelFilter.operator !== 'not set'">
 | 
					            <template v-if="modelFilter.operator !== 'set' && modelFilter.operator !== 'not set'">
 | 
				
			||||||
              <ComboBox
 | 
					              <SelectTag
 | 
				
			||||||
                v-if="getFieldOptions(modelFilter).length > 0"
 | 
					                v-if="getFieldType(modelFilter) === FIELD_TYPE.MULTI_SELECT"
 | 
				
			||||||
                v-model="modelFilter.value"
 | 
					                v-model="modelFilter.value"
 | 
				
			||||||
                :items="getFieldOptions(modelFilter)"
 | 
					                :items="getFieldOptions(modelFilter)"
 | 
				
			||||||
                :placeholder="t('form.field.select')"
 | 
					                :placeholder="t('globals.messages.select', { name: t('globals.terms.tag', 2) })"
 | 
				
			||||||
              >
 | 
					              />
 | 
				
			||||||
                <template #item="{ item }">
 | 
					
 | 
				
			||||||
                  <div v-if="modelFilter.field === 'assigned_user_id'">
 | 
					              <SelectComboBox
 | 
				
			||||||
                    <div class="flex items-center gap-1">
 | 
					                v-else-if="
 | 
				
			||||||
                      <Avatar class="w-6 h-6">
 | 
					                  getFieldOptions(modelFilter).length > 0 &&
 | 
				
			||||||
                        <AvatarImage :src="item.avatar_url || ''" :alt="item.label.slice(0, 2)" />
 | 
					                  modelFilter.field === 'assigned_user_id'
 | 
				
			||||||
                        <AvatarFallback>{{ item.label.slice(0, 2).toUpperCase() }} </AvatarFallback>
 | 
					                "
 | 
				
			||||||
                      </Avatar>
 | 
					                v-model="modelFilter.value"
 | 
				
			||||||
                      <span>{{ item.label }}</span>
 | 
					                :items="getFieldOptions(modelFilter)"
 | 
				
			||||||
                    </div>
 | 
					                :placeholder="t('globals.messages.select', { name: '' })"
 | 
				
			||||||
                  </div>
 | 
					                type="user"
 | 
				
			||||||
                  <div v-else-if="modelFilter.field === 'assigned_team_id'">
 | 
					              />
 | 
				
			||||||
                    <div class="flex items-center gap-2 ml-2">
 | 
					
 | 
				
			||||||
                      <span>{{ item.emoji }}</span>
 | 
					              <SelectComboBox
 | 
				
			||||||
                      <span>{{ item.label }}</span>
 | 
					                v-else-if="
 | 
				
			||||||
                    </div>
 | 
					                  getFieldOptions(modelFilter).length > 0 &&
 | 
				
			||||||
                  </div>
 | 
					                  modelFilter.field === 'assigned_team_id'
 | 
				
			||||||
                  <div v-else>
 | 
					                "
 | 
				
			||||||
                    {{ item.label }}
 | 
					                v-model="modelFilter.value"
 | 
				
			||||||
                  </div>
 | 
					                :items="getFieldOptions(modelFilter)"
 | 
				
			||||||
                </template>
 | 
					                :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
 | 
					              <Input
 | 
				
			||||||
                v-else
 | 
					                v-else
 | 
				
			||||||
                v-model="modelFilter.value"
 | 
					                v-model="modelFilter.value"
 | 
				
			||||||
                class="bg-transparent hover:bg-slate-100"
 | 
					                :placeholder="t('globals.terms.value')"
 | 
				
			||||||
                :placeholder="t('form.field.value')"
 | 
					 | 
				
			||||||
                type="text"
 | 
					                type="text"
 | 
				
			||||||
              />
 | 
					              />
 | 
				
			||||||
            </template>
 | 
					            </template>
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
 | 
					      <CloseButton :onClose="() => removeFilter(index)" />
 | 
				
			||||||
      <button @click="removeFilter(index)" class="p-1 hover:bg-slate-100 rounded">
 | 
					 | 
				
			||||||
        <X class="w-4 h-4 text-slate-500" />
 | 
					 | 
				
			||||||
      </button>
 | 
					 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <!-- Button Container -->
 | 
				
			||||||
    <div class="flex items-center justify-between pt-3">
 | 
					    <div class="flex items-center justify-between pt-3">
 | 
				
			||||||
      <Button variant="ghost" size="sm" @click="addFilter" class="text-slate-600">
 | 
					      <Button variant="ghost" size="sm" @click.stop="addFilter" class="text-slate-600">
 | 
				
			||||||
        <Plus class="w-3 h-3 mr-1" />
 | 
					        <Plus class="w-3 h-3 mr-1" />
 | 
				
			||||||
        {{
 | 
					        {{
 | 
				
			||||||
          $t('globals.messages.add', {
 | 
					          $t('globals.messages.add', {
 | 
				
			||||||
@@ -129,15 +112,17 @@
 | 
				
			|||||||
        }}
 | 
					        }}
 | 
				
			||||||
      </Button>
 | 
					      </Button>
 | 
				
			||||||
      <div class="flex gap-2" v-if="showButtons">
 | 
					      <div class="flex gap-2" v-if="showButtons">
 | 
				
			||||||
        <Button variant="ghost" @click="clearFilters">{{ $t('globals.buttons.reset') }}</Button>
 | 
					        <Button variant="ghost" @click.stop="clearFilters">
 | 
				
			||||||
        <Button @click="applyFilters">{{ $t('globals.buttons.apply') }}</Button>
 | 
					          {{ $t('globals.messages.reset') }}
 | 
				
			||||||
 | 
					        </Button>
 | 
				
			||||||
 | 
					        <Button @click.stop="applyFilters">{{ $t('globals.messages.apply') }}</Button>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { computed, onMounted, watch } from 'vue'
 | 
					import { computed, onMounted, onUnmounted, watch } from 'vue'
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  Select,
 | 
					  Select,
 | 
				
			||||||
  SelectContent,
 | 
					  SelectContent,
 | 
				
			||||||
@@ -146,12 +131,14 @@ import {
 | 
				
			|||||||
  SelectTrigger,
 | 
					  SelectTrigger,
 | 
				
			||||||
  SelectValue
 | 
					  SelectValue
 | 
				
			||||||
} from '@/components/ui/select'
 | 
					} from '@/components/ui/select'
 | 
				
			||||||
import { Plus, X } from 'lucide-vue-next'
 | 
					import { Plus } from 'lucide-vue-next'
 | 
				
			||||||
import { Button } from '@/components/ui/button'
 | 
					import { Button } from '@/components/ui/button'
 | 
				
			||||||
import { Input } from '@/components/ui/input'
 | 
					import { Input } from '@/components/ui/input'
 | 
				
			||||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
 | 
					 | 
				
			||||||
import { useI18n } from 'vue-i18n'
 | 
					import { useI18n } from 'vue-i18n'
 | 
				
			||||||
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
 | 
					import { FIELD_TYPE } from '@/constants/filterConfig'
 | 
				
			||||||
 | 
					import CloseButton from '@/components/button/CloseButton.vue'
 | 
				
			||||||
 | 
					import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
 | 
				
			||||||
 | 
					import SelectTag from '@/components/ui/select/SelectTag.vue'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = defineProps({
 | 
					const props = defineProps({
 | 
				
			||||||
  fields: {
 | 
					  fields: {
 | 
				
			||||||
@@ -175,12 +162,17 @@ onMounted(() => {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					onUnmounted(() => {
 | 
				
			||||||
 | 
					  // On unmounted set valid filters
 | 
				
			||||||
 | 
					  modelValue.value = validFilters.value
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const getModel = (field) => {
 | 
					const getModel = (field) => {
 | 
				
			||||||
  const fieldConfig = props.fields.find((f) => f.field === field)
 | 
					  const fieldConfig = props.fields.find((f) => f.field === field)
 | 
				
			||||||
  return fieldConfig?.model || ''
 | 
					  return fieldConfig?.model || ''
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Set model for each filter
 | 
					// Set model for each filter and the default value
 | 
				
			||||||
watch(
 | 
					watch(
 | 
				
			||||||
  () => modelValue.value,
 | 
					  () => modelValue.value,
 | 
				
			||||||
  (filters) => {
 | 
					  (filters) => {
 | 
				
			||||||
@@ -188,6 +180,15 @@ watch(
 | 
				
			|||||||
      if (filter.field && !filter.model) {
 | 
					      if (filter.field && !filter.model) {
 | 
				
			||||||
        filter.model = getModel(filter.field)
 | 
					        filter.model = getModel(filter.field)
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Multi select need arrays as their default value
 | 
				
			||||||
 | 
					      if (
 | 
				
			||||||
 | 
					        filter.field &&
 | 
				
			||||||
 | 
					        getFieldType(filter) === FIELD_TYPE.MULTI_SELECT &&
 | 
				
			||||||
 | 
					        !Array.isArray(filter.value)
 | 
				
			||||||
 | 
					      ) {
 | 
				
			||||||
 | 
					        filter.value = []
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  { deep: true }
 | 
					  { deep: true }
 | 
				
			||||||
@@ -195,15 +196,20 @@ watch(
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
// Reset operator and value when field changes for a filter at a given index
 | 
					// Reset operator and value when field changes for a filter at a given index
 | 
				
			||||||
watch(
 | 
					watch(
 | 
				
			||||||
  () => modelValue.value.map((f) => f.field),
 | 
					  modelValue,
 | 
				
			||||||
  (newFields, oldFields) => {
 | 
					  (newFilters, oldFilters) => {
 | 
				
			||||||
    newFields.forEach((field, index) => {
 | 
					    // Skip first run
 | 
				
			||||||
      if (field !== oldFields[index]) {
 | 
					    if (!oldFilters) return
 | 
				
			||||||
        modelValue.value[index].operator = ''
 | 
					
 | 
				
			||||||
        modelValue.value[index].value = ''
 | 
					    newFilters.forEach((filter, index) => {
 | 
				
			||||||
 | 
					      const oldFilter = oldFilters[index]
 | 
				
			||||||
 | 
					      if (oldFilter && filter.field !== oldFilter.field) {
 | 
				
			||||||
 | 
					        filter.operator = ''
 | 
				
			||||||
 | 
					        filter.value = ''
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
  }
 | 
					  },
 | 
				
			||||||
 | 
					  { deep: true }
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const addFilter = () => {
 | 
					const addFilter = () => {
 | 
				
			||||||
@@ -222,7 +228,17 @@ const clearFilters = () => {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const validFilters = computed(() => {
 | 
					const validFilters = computed(() => {
 | 
				
			||||||
  return modelValue.value.filter((filter) => filter.field && filter.operator && filter.value)
 | 
					  return modelValue.value.filter((filter) => {
 | 
				
			||||||
 | 
					    // For multi-select field type, allow empty array as a valid value
 | 
				
			||||||
 | 
					    const field = props.fields.find((f) => f.field === filter.field)
 | 
				
			||||||
 | 
					    const isMultiSelectField = field?.type === FIELD_TYPE.MULTI_SELECT
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (isMultiSelectField) {
 | 
				
			||||||
 | 
					      return filter.field && filter.operator && filter.value !== undefined && filter.value !== null
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return filter.field && filter.operator && filter.value
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const getFieldOptions = (fieldValue) => {
 | 
					const getFieldOptions = (fieldValue) => {
 | 
				
			||||||
@@ -234,4 +250,9 @@ const getFieldOperators = (modelFilter) => {
 | 
				
			|||||||
  const field = props.fields.find((f) => f.field === modelFilter.field)
 | 
					  const field = props.fields.find((f) => f.field === modelFilter.field)
 | 
				
			||||||
  return field?.operators || []
 | 
					  return field?.operators || []
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const getFieldType = (modelFilter) => {
 | 
				
			||||||
 | 
					  const field = props.fields.find((f) => f.field === modelFilter.field)
 | 
				
			||||||
 | 
					  return field?.type || ''
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -4,14 +4,14 @@
 | 
				
			|||||||
    @click="handleClick">
 | 
					    @click="handleClick">
 | 
				
			||||||
    <div class="flex items-center mb-2">
 | 
					    <div class="flex items-center mb-2">
 | 
				
			||||||
      <component :is="icon" size="24" class="mr-2 text-primary" />
 | 
					      <component :is="icon" size="24" class="mr-2 text-primary" />
 | 
				
			||||||
      <h3 class="text-lg font-medium text-gray-800">{{ title }}</h3>
 | 
					      <h3 class="text-lg font-medium">{{ title }}</h3>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
    <p class="text-sm text-gray-600">{{ subTitle }}</p>
 | 
					    <p class="text-sm text-gray-600 dark:text-gray-400">{{ subTitle }}</p>
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { defineProps, defineEmits } from 'vue'
 | 
					import { defineEmits } from 'vue'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = defineProps({
 | 
					const props = defineProps({
 | 
				
			||||||
  title: String,
 | 
					  title: String,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -5,7 +5,7 @@ import {
 | 
				
			|||||||
  accountNavItems,
 | 
					  accountNavItems,
 | 
				
			||||||
  contactNavItems
 | 
					  contactNavItems
 | 
				
			||||||
} from '@/constants/navigation'
 | 
					} from '@/constants/navigation'
 | 
				
			||||||
import { RouterLink, useRoute } from 'vue-router'
 | 
					import { RouterLink, useRoute, useRouter } from 'vue-router'
 | 
				
			||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
 | 
					import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  Sidebar,
 | 
					  Sidebar,
 | 
				
			||||||
@@ -30,7 +30,7 @@ import {
 | 
				
			|||||||
  Search,
 | 
					  Search,
 | 
				
			||||||
  Plus,
 | 
					  Plus,
 | 
				
			||||||
  CircleDashed,
 | 
					  CircleDashed,
 | 
				
			||||||
  List,
 | 
					  List
 | 
				
			||||||
} from 'lucide-vue-next'
 | 
					} from 'lucide-vue-next'
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  DropdownMenu,
 | 
					  DropdownMenu,
 | 
				
			||||||
@@ -38,38 +38,35 @@ import {
 | 
				
			|||||||
  DropdownMenuItem,
 | 
					  DropdownMenuItem,
 | 
				
			||||||
  DropdownMenuTrigger
 | 
					  DropdownMenuTrigger
 | 
				
			||||||
} from '@/components/ui/dropdown-menu'
 | 
					} from '@/components/ui/dropdown-menu'
 | 
				
			||||||
 | 
					import {
 | 
				
			||||||
 | 
					  AlertDialog,
 | 
				
			||||||
 | 
					  AlertDialogAction,
 | 
				
			||||||
 | 
					  AlertDialogCancel,
 | 
				
			||||||
 | 
					  AlertDialogContent,
 | 
				
			||||||
 | 
					  AlertDialogDescription,
 | 
				
			||||||
 | 
					  AlertDialogFooter,
 | 
				
			||||||
 | 
					  AlertDialogHeader,
 | 
				
			||||||
 | 
					  AlertDialogTitle
 | 
				
			||||||
 | 
					} from '@/components/ui/alert-dialog'
 | 
				
			||||||
import { filterNavItems } from '@/utils/nav-permissions'
 | 
					import { filterNavItems } from '@/utils/nav-permissions'
 | 
				
			||||||
import { useStorage } from '@vueuse/core'
 | 
					import { useStorage } from '@vueuse/core'
 | 
				
			||||||
import { computed } from 'vue'
 | 
					import { computed, ref, watch } from 'vue'
 | 
				
			||||||
import { useI18n } from 'vue-i18n'
 | 
					import { useI18n } from 'vue-i18n'
 | 
				
			||||||
import { useUserStore } from '@/stores/user'
 | 
					import { useUserStore } from '@/stores/user'
 | 
				
			||||||
 | 
					import { useConversationStore } from '@/stores/conversation'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
defineProps({
 | 
					defineProps({
 | 
				
			||||||
  userTeams: { type: Array, default: () => [] },
 | 
					  userTeams: { type: Array, default: () => [] },
 | 
				
			||||||
  userViews: { type: Array, default: () => [] }
 | 
					  userViews: { type: Array, default: () => [] }
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
const userStore = useUserStore()
 | 
					const userStore = useUserStore()
 | 
				
			||||||
 | 
					const conversationStore = useConversationStore()
 | 
				
			||||||
const settingsStore = useAppSettingsStore()
 | 
					const settingsStore = useAppSettingsStore()
 | 
				
			||||||
const route = useRoute()
 | 
					const route = useRoute()
 | 
				
			||||||
 | 
					const router = useRouter()
 | 
				
			||||||
const { t } = useI18n()
 | 
					const { t } = useI18n()
 | 
				
			||||||
const emit = defineEmits(['createView', 'editView', 'deleteView', 'createConversation'])
 | 
					const emit = defineEmits(['createView', 'editView', 'deleteView', 'createConversation'])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const openCreateViewDialog = () => {
 | 
					 | 
				
			||||||
  emit('createView')
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const editView = (view) => {
 | 
					 | 
				
			||||||
  emit('editView', view)
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const deleteView = (view) => {
 | 
					 | 
				
			||||||
  emit('deleteView', view)
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const filteredAdminNavItems = computed(() => filterNavItems(adminNavItems, userStore.can))
 | 
					 | 
				
			||||||
const filteredReportsNavItems = computed(() => filterNavItems(reportsNavItems, userStore.can))
 | 
					 | 
				
			||||||
const filteredContactsNavItems = computed(() => filterNavItems(contactNavItems, userStore.can))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const isActiveParent = (parentHref) => {
 | 
					const isActiveParent = (parentHref) => {
 | 
				
			||||||
  return route.path.startsWith(parentHref)
 | 
					  return route.path.startsWith(parentHref)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -78,9 +75,114 @@ const isInboxRoute = (path) => {
 | 
				
			|||||||
  return path.startsWith('/inboxes')
 | 
					  return path.startsWith('/inboxes')
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const openCreateViewDialog = () => {
 | 
				
			||||||
 | 
					  emit('createView')
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const editView = (view) => {
 | 
				
			||||||
 | 
					  emit('editView', view)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const openDeleteConfirmation = (view) => {
 | 
				
			||||||
 | 
					  viewToDelete.value = view
 | 
				
			||||||
 | 
					  isDeleteOpen.value = true
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const handleDeleteView = () => {
 | 
				
			||||||
 | 
					  if (viewToDelete.value) {
 | 
				
			||||||
 | 
					    emit('deleteView', viewToDelete.value)
 | 
				
			||||||
 | 
					    isDeleteOpen.value = false
 | 
				
			||||||
 | 
					    viewToDelete.value = null
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Navigation methods with conversation retention
 | 
				
			||||||
 | 
					const navigateToInbox = (type) => {
 | 
				
			||||||
 | 
					  if (conversationStore.hasConversationOpen && conversationStore.conversation.data?.uuid) {
 | 
				
			||||||
 | 
					    router.push({
 | 
				
			||||||
 | 
					      name: 'inbox-conversation',
 | 
				
			||||||
 | 
					      params: {
 | 
				
			||||||
 | 
					        type,
 | 
				
			||||||
 | 
					        uuid: conversationStore.conversation.data.uuid
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					  } else {
 | 
				
			||||||
 | 
					    router.push({
 | 
				
			||||||
 | 
					      name: 'inbox',
 | 
				
			||||||
 | 
					      params: { type }
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const navigateToTeamInbox = (teamID) => {
 | 
				
			||||||
 | 
					  if (conversationStore.hasConversationOpen && conversationStore.conversation.data?.uuid) {
 | 
				
			||||||
 | 
					    router.push({
 | 
				
			||||||
 | 
					      name: 'team-inbox-conversation',
 | 
				
			||||||
 | 
					      params: {
 | 
				
			||||||
 | 
					        teamID,
 | 
				
			||||||
 | 
					        uuid: conversationStore.conversation.data.uuid
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					  } else {
 | 
				
			||||||
 | 
					    router.push({
 | 
				
			||||||
 | 
					      name: 'team-inbox',
 | 
				
			||||||
 | 
					      params: { teamID }
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const navigateToViewInbox = (viewID) => {
 | 
				
			||||||
 | 
					  if (conversationStore.hasConversationOpen && conversationStore.conversation.data?.uuid) {
 | 
				
			||||||
 | 
					    router.push({
 | 
				
			||||||
 | 
					      name: 'view-inbox-conversation',
 | 
				
			||||||
 | 
					      params: {
 | 
				
			||||||
 | 
					        viewID,
 | 
				
			||||||
 | 
					        uuid: conversationStore.conversation.data.uuid
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					  } else {
 | 
				
			||||||
 | 
					    router.push({
 | 
				
			||||||
 | 
					      name: 'view-inbox',
 | 
				
			||||||
 | 
					      params: { viewID }
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const filteredAdminNavItems = computed(() => filterNavItems(adminNavItems, userStore.can))
 | 
				
			||||||
 | 
					const filteredReportsNavItems = computed(() => filterNavItems(reportsNavItems, userStore.can))
 | 
				
			||||||
 | 
					const filteredContactsNavItems = computed(() => filterNavItems(contactNavItems, userStore.can))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// 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 sidebarOpen = useStorage('mainSidebarOpen', true)
 | 
				
			||||||
const teamInboxOpen = useStorage('teamInboxOpen', true)
 | 
					const teamInboxOpen = useStorage('teamInboxOpen', true)
 | 
				
			||||||
const viewInboxOpen = useStorage('viewInboxOpen', true)
 | 
					const viewInboxOpen = useStorage('viewInboxOpen', true)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Track which view is being hovered for ellipsis menu visibility
 | 
				
			||||||
 | 
					const hoveredViewId = ref(null)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Track delete confirmation dialog state
 | 
				
			||||||
 | 
					const isDeleteOpen = ref(false)
 | 
				
			||||||
 | 
					const viewToDelete = ref(null)
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<template>
 | 
					<template>
 | 
				
			||||||
@@ -111,7 +213,11 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
 | 
				
			|||||||
              <SidebarMenuItem v-for="item in filteredContactsNavItems" :key="item.titleKey">
 | 
					              <SidebarMenuItem v-for="item in filteredContactsNavItems" :key="item.titleKey">
 | 
				
			||||||
                <SidebarMenuButton :isActive="isActiveParent(item.href)" asChild>
 | 
					                <SidebarMenuButton :isActive="isActiveParent(item.href)" asChild>
 | 
				
			||||||
                  <router-link :to="item.href">
 | 
					                  <router-link :to="item.href">
 | 
				
			||||||
                    <span>{{ t(item.titleKey) }}</span>
 | 
					                    <span>{{
 | 
				
			||||||
 | 
					                      t('globals.messages.all', {
 | 
				
			||||||
 | 
					                        name: t(item.titleKey, 2).toLowerCase()
 | 
				
			||||||
 | 
					                      })
 | 
				
			||||||
 | 
					                    }}</span>
 | 
				
			||||||
                  </router-link>
 | 
					                  </router-link>
 | 
				
			||||||
                </SidebarMenuButton>
 | 
					                </SidebarMenuButton>
 | 
				
			||||||
              </SidebarMenuItem>
 | 
					              </SidebarMenuItem>
 | 
				
			||||||
@@ -135,7 +241,7 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
 | 
				
			|||||||
            <SidebarMenuItem>
 | 
					            <SidebarMenuItem>
 | 
				
			||||||
              <div class="px-1">
 | 
					              <div class="px-1">
 | 
				
			||||||
                <span class="font-semibold text-xl">
 | 
					                <span class="font-semibold text-xl">
 | 
				
			||||||
                  {{ t('navigation.reports') }}
 | 
					                  {{ t('globals.terms.report', 2) }}
 | 
				
			||||||
                </span>
 | 
					                </span>
 | 
				
			||||||
              </div>
 | 
					              </div>
 | 
				
			||||||
            </SidebarMenuItem>
 | 
					            </SidebarMenuItem>
 | 
				
			||||||
@@ -166,7 +272,7 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
 | 
				
			|||||||
            <SidebarMenuItem>
 | 
					            <SidebarMenuItem>
 | 
				
			||||||
              <div class="flex flex-col items-start justify-between w-full px-1">
 | 
					              <div class="flex flex-col items-start justify-between w-full px-1">
 | 
				
			||||||
                <span class="font-semibold text-xl">
 | 
					                <span class="font-semibold text-xl">
 | 
				
			||||||
                  {{ t('navigation.admin') }}
 | 
					                  {{ t('globals.terms.admin') }}
 | 
				
			||||||
                </span>
 | 
					                </span>
 | 
				
			||||||
                <!-- App version -->
 | 
					                <!-- App version -->
 | 
				
			||||||
                <div class="text-xs text-muted-foreground">
 | 
					                <div class="text-xs text-muted-foreground">
 | 
				
			||||||
@@ -193,11 +299,12 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
 | 
				
			|||||||
                <Collapsible
 | 
					                <Collapsible
 | 
				
			||||||
                  v-else
 | 
					                  v-else
 | 
				
			||||||
                  class="group/collapsible"
 | 
					                  class="group/collapsible"
 | 
				
			||||||
                  :default-open="isActiveParent(item.href)"
 | 
					                  :open="openAdminCollapsible === item.titleKey"
 | 
				
			||||||
 | 
					                  @update:open="toggleAdminCollapsible(item.titleKey)"
 | 
				
			||||||
                >
 | 
					                >
 | 
				
			||||||
                  <CollapsibleTrigger as-child>
 | 
					                  <CollapsibleTrigger as-child>
 | 
				
			||||||
                    <SidebarMenuButton :isActive="isActiveParent(item.href)">
 | 
					                    <SidebarMenuButton :isActive="isActiveParent(item.href)">
 | 
				
			||||||
                      <span>{{ t(item.titleKey) }}</span>
 | 
					                      <span>{{ t(item.titleKey, item.isTitleKeyPlural === true ? 2 : 1) }}</span>
 | 
				
			||||||
                      <ChevronRight
 | 
					                      <ChevronRight
 | 
				
			||||||
                        class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
 | 
					                        class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
 | 
				
			||||||
                      />
 | 
					                      />
 | 
				
			||||||
@@ -231,7 +338,7 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
 | 
				
			|||||||
            <SidebarMenuItem>
 | 
					            <SidebarMenuItem>
 | 
				
			||||||
              <div class="px-1">
 | 
					              <div class="px-1">
 | 
				
			||||||
                <span class="font-semibold text-xl">
 | 
					                <span class="font-semibold text-xl">
 | 
				
			||||||
                  {{ t('navigation.account') }}
 | 
					                  {{ t('globals.terms.account') }}
 | 
				
			||||||
                </span>
 | 
					                </span>
 | 
				
			||||||
              </div>
 | 
					              </div>
 | 
				
			||||||
            </SidebarMenuItem>
 | 
					            </SidebarMenuItem>
 | 
				
			||||||
@@ -265,7 +372,7 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
 | 
				
			|||||||
            <SidebarMenuItem>
 | 
					            <SidebarMenuItem>
 | 
				
			||||||
              <div class="flex items-center justify-between w-full px-1">
 | 
					              <div class="flex items-center justify-between w-full px-1">
 | 
				
			||||||
                <div class="font-semibold text-xl">
 | 
					                <div class="font-semibold text-xl">
 | 
				
			||||||
                  <span>{{ t('navigation.inbox') }}</span>
 | 
					                  <span>{{ t('globals.terms.inbox') }}</span>
 | 
				
			||||||
                </div>
 | 
					                </div>
 | 
				
			||||||
                <div class="mr-1 mt-1 hover:scale-110 transition-transform">
 | 
					                <div class="mr-1 mt-1 hover:scale-110 transition-transform">
 | 
				
			||||||
                  <router-link :to="{ name: 'search' }">
 | 
					                  <router-link :to="{ name: 'search' }">
 | 
				
			||||||
@@ -296,32 +403,32 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
 | 
				
			|||||||
              </SidebarMenuItem>
 | 
					              </SidebarMenuItem>
 | 
				
			||||||
              <SidebarMenuItem>
 | 
					              <SidebarMenuItem>
 | 
				
			||||||
                <SidebarMenuButton asChild :isActive="isActiveParent('/inboxes/assigned')">
 | 
					                <SidebarMenuButton asChild :isActive="isActiveParent('/inboxes/assigned')">
 | 
				
			||||||
                  <router-link :to="{ name: 'inbox', params: { type: 'assigned' } }">
 | 
					                  <a href="#" @click.prevent="navigateToInbox('assigned')">
 | 
				
			||||||
                    <User />
 | 
					                    <User />
 | 
				
			||||||
                    <span>{{ t('navigation.myInbox') }}</span>
 | 
					                    <span>{{ t('globals.terms.myInbox') }}</span>
 | 
				
			||||||
                  </router-link>
 | 
					                  </a>
 | 
				
			||||||
                </SidebarMenuButton>
 | 
					                </SidebarMenuButton>
 | 
				
			||||||
              </SidebarMenuItem>
 | 
					              </SidebarMenuItem>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
              <SidebarMenuItem>
 | 
					              <SidebarMenuItem>
 | 
				
			||||||
                <SidebarMenuButton asChild :isActive="isActiveParent('/inboxes/unassigned')">
 | 
					                <SidebarMenuButton asChild :isActive="isActiveParent('/inboxes/unassigned')">
 | 
				
			||||||
                  <router-link :to="{ name: 'inbox', params: { type: 'unassigned' } }">
 | 
					                  <a href="#" @click.prevent="navigateToInbox('unassigned')">
 | 
				
			||||||
                    <CircleDashed />
 | 
					                    <CircleDashed />
 | 
				
			||||||
                    <span>
 | 
					                    <span>
 | 
				
			||||||
                      {{ t('navigation.unassigned') }}
 | 
					                      {{ t('globals.terms.unassigned') }}
 | 
				
			||||||
                    </span>
 | 
					                    </span>
 | 
				
			||||||
                  </router-link>
 | 
					                  </a>
 | 
				
			||||||
                </SidebarMenuButton>
 | 
					                </SidebarMenuButton>
 | 
				
			||||||
              </SidebarMenuItem>
 | 
					              </SidebarMenuItem>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
              <SidebarMenuItem>
 | 
					              <SidebarMenuItem>
 | 
				
			||||||
                <SidebarMenuButton asChild :isActive="isActiveParent('/inboxes/all')">
 | 
					                <SidebarMenuButton asChild :isActive="isActiveParent('/inboxes/all')">
 | 
				
			||||||
                  <router-link :to="{ name: 'inbox', params: { type: 'all' } }">
 | 
					                  <a href="#" @click.prevent="navigateToInbox('all')">
 | 
				
			||||||
                    <List />
 | 
					                    <List />
 | 
				
			||||||
                    <span>
 | 
					                    <span>
 | 
				
			||||||
                      {{ t('globals.messages.all') }}
 | 
					                      {{ t('globals.messages.all') }}
 | 
				
			||||||
                    </span>
 | 
					                    </span>
 | 
				
			||||||
                  </router-link>
 | 
					                  </a>
 | 
				
			||||||
                </SidebarMenuButton>
 | 
					                </SidebarMenuButton>
 | 
				
			||||||
              </SidebarMenuItem>
 | 
					              </SidebarMenuItem>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -338,7 +445,7 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
 | 
				
			|||||||
                      <router-link to="#">
 | 
					                      <router-link to="#">
 | 
				
			||||||
                        <!-- <Users /> -->
 | 
					                        <!-- <Users /> -->
 | 
				
			||||||
                        <span>
 | 
					                        <span>
 | 
				
			||||||
                          {{ t('navigation.teamInboxes') }}
 | 
					                          {{ t('globals.terms.teamInbox', 2) }}
 | 
				
			||||||
                        </span>
 | 
					                        </span>
 | 
				
			||||||
                        <ChevronRight
 | 
					                        <ChevronRight
 | 
				
			||||||
                          class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
 | 
					                          class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
 | 
				
			||||||
@@ -354,9 +461,9 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
 | 
				
			|||||||
                          :is-active="route.params.teamID == team.id"
 | 
					                          :is-active="route.params.teamID == team.id"
 | 
				
			||||||
                          asChild
 | 
					                          asChild
 | 
				
			||||||
                        >
 | 
					                        >
 | 
				
			||||||
                          <router-link :to="{ name: 'team-inbox', params: { teamID: team.id } }">
 | 
					                          <a href="#" @click.prevent="navigateToTeamInbox(team.id)">
 | 
				
			||||||
                            {{ team.emoji }}<span>{{ team.name }}</span>
 | 
					                            {{ team.emoji }}<span>{{ team.name }}</span>
 | 
				
			||||||
                          </router-link>
 | 
					                          </a>
 | 
				
			||||||
                        </SidebarMenuButton>
 | 
					                        </SidebarMenuButton>
 | 
				
			||||||
                      </SidebarMenuSubItem>
 | 
					                      </SidebarMenuSubItem>
 | 
				
			||||||
                    </SidebarMenuSub>
 | 
					                    </SidebarMenuSub>
 | 
				
			||||||
@@ -372,7 +479,7 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
 | 
				
			|||||||
                      <router-link to="#" class="group/item !p-2">
 | 
					                      <router-link to="#" class="group/item !p-2">
 | 
				
			||||||
                        <!-- <SlidersHorizontal /> -->
 | 
					                        <!-- <SlidersHorizontal /> -->
 | 
				
			||||||
                        <span>
 | 
					                        <span>
 | 
				
			||||||
                          {{ t('navigation.views') }}
 | 
					                          {{ t('globals.terms.view', 2) }}
 | 
				
			||||||
                        </span>
 | 
					                        </span>
 | 
				
			||||||
                        <div>
 | 
					                        <div>
 | 
				
			||||||
                          <Plus
 | 
					                          <Plus
 | 
				
			||||||
@@ -391,30 +498,41 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
                  <CollapsibleContent>
 | 
					                  <CollapsibleContent>
 | 
				
			||||||
                    <SidebarMenuSub v-for="view in userViews" :key="view.id">
 | 
					                    <SidebarMenuSub v-for="view in userViews" :key="view.id">
 | 
				
			||||||
                      <SidebarMenuSubItem>
 | 
					                      <SidebarMenuSubItem
 | 
				
			||||||
 | 
					                        @mouseenter="hoveredViewId = view.id"
 | 
				
			||||||
 | 
					                        @mouseleave="hoveredViewId = null"
 | 
				
			||||||
 | 
					                      >
 | 
				
			||||||
                        <SidebarMenuButton
 | 
					                        <SidebarMenuButton
 | 
				
			||||||
                          size="sm"
 | 
					                          size="sm"
 | 
				
			||||||
                          :isActive="route.params.viewID == view.id"
 | 
					                          :isActive="route.params.viewID == view.id"
 | 
				
			||||||
                          asChild
 | 
					                          asChild
 | 
				
			||||||
                        >
 | 
					                        >
 | 
				
			||||||
                          <router-link :to="{ name: 'view-inbox', params: { viewID: view.id } }">
 | 
					                          <a href="#" @click.prevent="navigateToViewInbox(view.id)">
 | 
				
			||||||
                            <span class="break-words w-32 truncate">{{ view.name }}</span>
 | 
					                            <span class="break-words w-32 truncate" :title="view.name">{{ view.name }}</span>
 | 
				
			||||||
                            <SidebarMenuAction :showOnHover="true" class="mr-3">
 | 
					                            <SidebarMenuAction
 | 
				
			||||||
 | 
					                              @click.stop
 | 
				
			||||||
 | 
					                              :class="[
 | 
				
			||||||
 | 
					                                'mr-3',
 | 
				
			||||||
 | 
					                                'md:opacity-0',
 | 
				
			||||||
 | 
					                                'data-[state=open]:opacity-100',
 | 
				
			||||||
 | 
					                                { 'md:opacity-100': hoveredViewId === view.id }
 | 
				
			||||||
 | 
					                              ]"
 | 
				
			||||||
 | 
					                            >
 | 
				
			||||||
                              <DropdownMenu>
 | 
					                              <DropdownMenu>
 | 
				
			||||||
                                <DropdownMenuTrigger asChild>
 | 
					                                <DropdownMenuTrigger asChild @click.prevent>
 | 
				
			||||||
                                  <EllipsisVertical />
 | 
					                                  <EllipsisVertical />
 | 
				
			||||||
                                </DropdownMenuTrigger>
 | 
					                                </DropdownMenuTrigger>
 | 
				
			||||||
                                <DropdownMenuContent>
 | 
					                                <DropdownMenuContent>
 | 
				
			||||||
                                  <DropdownMenuItem @click="() => editView(view)">
 | 
					                                  <DropdownMenuItem @click="() => editView(view)">
 | 
				
			||||||
                                    <span>{{ t('globals.buttons.edit') }}</span>
 | 
					                                    <span>{{ t('globals.messages.edit') }}</span>
 | 
				
			||||||
                                  </DropdownMenuItem>
 | 
					                                  </DropdownMenuItem>
 | 
				
			||||||
                                  <DropdownMenuItem @click="() => deleteView(view)">
 | 
					                                  <DropdownMenuItem @click="() => openDeleteConfirmation(view)">
 | 
				
			||||||
                                    <span>{{ t('globals.buttons.delete') }}</span>
 | 
					                                    <span>{{ t('globals.messages.delete') }}</span>
 | 
				
			||||||
                                  </DropdownMenuItem>
 | 
					                                  </DropdownMenuItem>
 | 
				
			||||||
                                </DropdownMenuContent>
 | 
					                                </DropdownMenuContent>
 | 
				
			||||||
                              </DropdownMenu>
 | 
					                              </DropdownMenu>
 | 
				
			||||||
                            </SidebarMenuAction>
 | 
					                            </SidebarMenuAction>
 | 
				
			||||||
                          </router-link>
 | 
					                          </a>
 | 
				
			||||||
                        </SidebarMenuButton>
 | 
					                        </SidebarMenuButton>
 | 
				
			||||||
                      </SidebarMenuSubItem>
 | 
					                      </SidebarMenuSubItem>
 | 
				
			||||||
                    </SidebarMenuSub>
 | 
					                    </SidebarMenuSub>
 | 
				
			||||||
@@ -432,4 +550,22 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
 | 
				
			|||||||
      <slot></slot>
 | 
					      <slot></slot>
 | 
				
			||||||
    </SidebarInset>
 | 
					    </SidebarInset>
 | 
				
			||||||
  </SidebarProvider>
 | 
					  </SidebarProvider>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  <!-- View Delete Confirmation Dialog -->
 | 
				
			||||||
 | 
					  <AlertDialog v-model:open="isDeleteOpen">
 | 
				
			||||||
 | 
					    <AlertDialogContent>
 | 
				
			||||||
 | 
					      <AlertDialogHeader>
 | 
				
			||||||
 | 
					        <AlertDialogTitle>{{ t('globals.messages.areYouAbsolutelySure') }}</AlertDialogTitle>
 | 
				
			||||||
 | 
					        <AlertDialogDescription>
 | 
				
			||||||
 | 
					          {{ t('globals.messages.deletionConfirmation', { name: t('globals.terms.view') }) }}
 | 
				
			||||||
 | 
					        </AlertDialogDescription>
 | 
				
			||||||
 | 
					      </AlertDialogHeader>
 | 
				
			||||||
 | 
					      <AlertDialogFooter>
 | 
				
			||||||
 | 
					        <AlertDialogCancel>{{ t('globals.messages.cancel') }}</AlertDialogCancel>
 | 
				
			||||||
 | 
					        <AlertDialogAction @click="handleDeleteView">
 | 
				
			||||||
 | 
					          {{ t('globals.messages.delete') }}
 | 
				
			||||||
 | 
					        </AlertDialogAction>
 | 
				
			||||||
 | 
					      </AlertDialogFooter>
 | 
				
			||||||
 | 
					    </AlertDialogContent>
 | 
				
			||||||
 | 
					  </AlertDialog>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -96,7 +96,7 @@
 | 
				
			|||||||
      <DropdownMenuGroup>
 | 
					      <DropdownMenuGroup>
 | 
				
			||||||
        <DropdownMenuItem @click.prevent="router.push({ name: 'account' })">
 | 
					        <DropdownMenuItem @click.prevent="router.push({ name: 'account' })">
 | 
				
			||||||
          <CircleUserRound size="18" class="mr-2" />
 | 
					          <CircleUserRound size="18" class="mr-2" />
 | 
				
			||||||
          {{ t('navigation.account') }}
 | 
					          {{ t('globals.terms.account') }}
 | 
				
			||||||
        </DropdownMenuItem>
 | 
					        </DropdownMenuItem>
 | 
				
			||||||
      </DropdownMenuGroup>
 | 
					      </DropdownMenuGroup>
 | 
				
			||||||
      <DropdownMenuSeparator />
 | 
					      <DropdownMenuSeparator />
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -10,13 +10,30 @@
 | 
				
			|||||||
        >
 | 
					        >
 | 
				
			||||||
          {{ header }}
 | 
					          {{ header }}
 | 
				
			||||||
        </th>
 | 
					        </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>
 | 
					      </tr>
 | 
				
			||||||
    </thead>
 | 
					    </thead>
 | 
				
			||||||
    <tbody class="bg-background divide-y divide-border">
 | 
					    <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>
 | 
					        <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">
 | 
					            <div class="flex flex-col items-center space-y-4">
 | 
				
			||||||
              <span class="text-md text-muted-foreground">
 | 
					              <span class="text-md text-muted-foreground">
 | 
				
			||||||
                {{
 | 
					                {{
 | 
				
			||||||
@@ -29,6 +46,8 @@
 | 
				
			|||||||
          </td>
 | 
					          </td>
 | 
				
			||||||
        </tr>
 | 
					        </tr>
 | 
				
			||||||
      </template>
 | 
					      </template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <!-- Data Rows -->
 | 
				
			||||||
      <template v-else>
 | 
					      <template v-else>
 | 
				
			||||||
        <tr v-for="(item, index) in data" :key="index" class="hover:bg-accent">
 | 
					        <tr v-for="(item, index) in data" :key="index" class="hover:bg-accent">
 | 
				
			||||||
          <td
 | 
					          <td
 | 
				
			||||||
@@ -51,8 +70,9 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { Trash2 } from 'lucide-vue-next'
 | 
					import { Trash2 } from 'lucide-vue-next'
 | 
				
			||||||
import { defineProps, defineEmits } from 'vue'
 | 
					import { defineEmits } from 'vue'
 | 
				
			||||||
import { Button } from '@/components/ui/button'
 | 
					import { Button } from '@/components/ui/button'
 | 
				
			||||||
 | 
					import { Skeleton } from '@/components/ui/skeleton'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
defineProps({
 | 
					defineProps({
 | 
				
			||||||
  headers: {
 | 
					  headers: {
 | 
				
			||||||
@@ -73,6 +93,14 @@ defineProps({
 | 
				
			|||||||
  showDelete: {
 | 
					  showDelete: {
 | 
				
			||||||
    type: Boolean,
 | 
					    type: Boolean,
 | 
				
			||||||
    default: true
 | 
					    default: true
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  loading: {
 | 
				
			||||||
 | 
					    type: Boolean,
 | 
				
			||||||
 | 
					    default: false
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  skeletonRows: {
 | 
				
			||||||
 | 
					    type: Number,
 | 
				
			||||||
 | 
					    default: 5
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,23 +1,39 @@
 | 
				
			|||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { Primitive } from 'reka-ui';
 | 
					import { Primitive } from 'reka-ui'
 | 
				
			||||||
import { cn } from '@/lib/utils';
 | 
					import { cn } from '@/lib/utils'
 | 
				
			||||||
import { buttonVariants } from '.';
 | 
					import { buttonVariants } from '.'
 | 
				
			||||||
 | 
					import { Loader2 } from 'lucide-vue-next'
 | 
				
			||||||
const props = defineProps({
 | 
					const props = defineProps({
 | 
				
			||||||
  variant: { type: null, required: false },
 | 
					  variant: { type: null, required: false },
 | 
				
			||||||
  size: { type: null, required: false },
 | 
					  size: { type: null, required: false },
 | 
				
			||||||
  class: { type: null, required: false },
 | 
					  class: { type: null, required: false },
 | 
				
			||||||
  asChild: { type: Boolean, required: false },
 | 
					  asChild: { type: Boolean, required: false },
 | 
				
			||||||
  as: { type: null, required: false, default: 'button' },
 | 
					  as: { type: null, required: false, default: 'button' },
 | 
				
			||||||
});
 | 
					  isLoading: { type: Boolean, required: false, default: false },
 | 
				
			||||||
 | 
					  disabled: { type: Boolean, required: false, default: false }
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <Primitive
 | 
					  <Primitive
 | 
				
			||||||
    :as="as"
 | 
					    :as="as"
 | 
				
			||||||
    :as-child="asChild"
 | 
					    :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 />
 | 
					    <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>
 | 
					  </Primitive>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -8,7 +8,7 @@
 | 
				
			|||||||
        :class="['w-full justify-between', buttonClass]"
 | 
					        :class="['w-full justify-between', buttonClass]"
 | 
				
			||||||
      >
 | 
					      >
 | 
				
			||||||
        <slot name="selected" :selected="selectedItem">{{ selectedLabel }}</slot>
 | 
					        <slot name="selected" :selected="selectedItem">{{ selectedLabel }}</slot>
 | 
				
			||||||
        <CaretSortIcon class="ml-2 h-4 w-4 shrink-0 opacity-50" />
 | 
					        <CaretSortIcon class="h-4 w-4 shrink-0 opacity-50" />
 | 
				
			||||||
      </Button>
 | 
					      </Button>
 | 
				
			||||||
    </PopoverTrigger>
 | 
					    </PopoverTrigger>
 | 
				
			||||||
    <PopoverContent class="p-0">
 | 
					    <PopoverContent class="p-0">
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										116
									
								
								frontend/src/components/ui/date-filter/DateFilter.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										116
									
								
								frontend/src/components/ui/date-filter/DateFilter.vue
									
									
									
									
									
										Normal 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>
 | 
				
			||||||
							
								
								
									
										1
									
								
								frontend/src/components/ui/date-filter/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								frontend/src/components/ui/date-filter/index.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
				
			|||||||
 | 
					export { default as DateFilter } from './DateFilter.vue'
 | 
				
			||||||
@@ -1,7 +1,11 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <span class="dot-loader">
 | 
					  <span class="inline-flex items-center">
 | 
				
			||||||
    <span class="dot"></span>
 | 
					    <span class="w-1 h-1 rounded-full bg-current mx-0.5 animate-dot-flashing"></span>
 | 
				
			||||||
    <span class="dot"></span>
 | 
					    <span
 | 
				
			||||||
    <span class="dot"></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>
 | 
					  </span>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,9 +1,10 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
 | 
					  <!-- idk why I named this select tag, should be named multi-select -->
 | 
				
			||||||
  <TagsInput v-model="tags" class="px-0 gap-0" :displayValue="getLabel">
 | 
					  <TagsInput v-model="tags" class="px-0 gap-0" :displayValue="getLabel">
 | 
				
			||||||
    <!-- Tags visible to the user -->
 | 
					    <!-- Tags visible to the user -->
 | 
				
			||||||
    <div class="flex gap-2 flex-wrap items-center px-3">
 | 
					    <div class="flex gap-2 flex-wrap items-center px-3">
 | 
				
			||||||
      <TagsInputItem v-for="tagValue in tags" :key="tagValue" :value="tagValue">
 | 
					      <TagsInputItem v-for="tagValue in tags" :key="tagValue" :value="tagValue">
 | 
				
			||||||
        <TagsInputItemText/>
 | 
					        <TagsInputItemText />
 | 
				
			||||||
        <TagsInputItemDelete />
 | 
					        <TagsInputItemDelete />
 | 
				
			||||||
      </TagsInputItem>
 | 
					      </TagsInputItem>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
@@ -23,6 +24,8 @@
 | 
				
			|||||||
            :class="tags.length > 0 ? 'mt-2' : ''"
 | 
					            :class="tags.length > 0 ? 'mt-2' : ''"
 | 
				
			||||||
            @keydown.enter.prevent
 | 
					            @keydown.enter.prevent
 | 
				
			||||||
            @blur="handleBlur"
 | 
					            @blur="handleBlur"
 | 
				
			||||||
 | 
					            @click="open = true"
 | 
				
			||||||
 | 
					            @input.stop
 | 
				
			||||||
          />
 | 
					          />
 | 
				
			||||||
        </ComboboxInput>
 | 
					        </ComboboxInput>
 | 
				
			||||||
      </ComboboxAnchor>
 | 
					      </ComboboxAnchor>
 | 
				
			||||||
@@ -99,11 +102,14 @@ const open = ref(false)
 | 
				
			|||||||
const searchTerm = ref('')
 | 
					const searchTerm = ref('')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Get all options that are not already selected and match the search term
 | 
					// 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(() => {
 | 
					const filteredOptions = computed(() => {
 | 
				
			||||||
  return props.items.filter(
 | 
					  const available = props.items.filter((item) => !tags.value.includes(item.value))
 | 
				
			||||||
    (item) =>
 | 
					
 | 
				
			||||||
      !tags.value.includes(item.value) &&
 | 
					  if (!searchTerm.value) return available
 | 
				
			||||||
      item.label.toLowerCase().includes(searchTerm.value.toLowerCase())
 | 
					
 | 
				
			||||||
 | 
					  return available.filter((item) =>
 | 
				
			||||||
 | 
					    item.label.toLowerCase().includes(searchTerm.value.toLowerCase())
 | 
				
			||||||
  )
 | 
					  )
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -127,6 +133,8 @@ const handleSelect = (event) => {
 | 
				
			|||||||
// Custom filter function to filter items based on the search term
 | 
					// Custom filter function to filter items based on the search term
 | 
				
			||||||
const filterFunc = (remainingItemValues, term) => {
 | 
					const filterFunc = (remainingItemValues, term) => {
 | 
				
			||||||
  const remainingItems = props.items.filter((item) => remainingItemValues.includes(item.value))
 | 
					  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>
 | 
					</script>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,25 +0,0 @@
 | 
				
			|||||||
<template>
 | 
					 | 
				
			||||||
  <div
 | 
					 | 
				
			||||||
    v-if="appSettingsStore.settings['app.update']?.update?.is_new"
 | 
					 | 
				
			||||||
    class="p-2 mb-2 border-b bg-secondary text-secondary-foreground"
 | 
					 | 
				
			||||||
  >
 | 
					 | 
				
			||||||
    {{ $t('update.newUpdateAvailable') }}:
 | 
					 | 
				
			||||||
    {{ appSettingsStore.settings['app.update'].update.release_version }} ({{
 | 
					 | 
				
			||||||
      appSettingsStore.settings['app.update'].update.release_date
 | 
					 | 
				
			||||||
    }})
 | 
					 | 
				
			||||||
    <a
 | 
					 | 
				
			||||||
      :href="appSettingsStore.settings['app.update'].update.url"
 | 
					 | 
				
			||||||
      target="_blank"
 | 
					 | 
				
			||||||
      nofollow
 | 
					 | 
				
			||||||
      noreferrer
 | 
					 | 
				
			||||||
      class="underline ml-2"
 | 
					 | 
				
			||||||
    >
 | 
					 | 
				
			||||||
      {{ $t('globals.messages.viewDetails') }}
 | 
					 | 
				
			||||||
    </a>
 | 
					 | 
				
			||||||
  </div>
 | 
					 | 
				
			||||||
</template>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
<script setup>
 | 
					 | 
				
			||||||
import { useAppSettingsStore } from '@/stores/appSettings'
 | 
					 | 
				
			||||||
const appSettingsStore = useAppSettingsStore()
 | 
					 | 
				
			||||||
</script>
 | 
					 | 
				
			||||||
@@ -1,18 +1,22 @@
 | 
				
			|||||||
import { computed } from 'vue'
 | 
					import { computed } from 'vue'
 | 
				
			||||||
import { useUsersStore } from '@/stores/users'
 | 
					import { useUsersStore } from '@/stores/users'
 | 
				
			||||||
import { FIELD_TYPE, FIELD_OPERATORS } from '@/constants/filterConfig'
 | 
					import { FIELD_TYPE, FIELD_OPERATORS } from '@/constants/filterConfig'
 | 
				
			||||||
 | 
					import { useI18n } from 'vue-i18n'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function useActivityLogFilters () {
 | 
					export function useActivityLogFilters () {
 | 
				
			||||||
    const uStore = useUsersStore()
 | 
					    const uStore = useUsersStore()
 | 
				
			||||||
 | 
					    const { t } = useI18n()
 | 
				
			||||||
    const activityLogListFilters = computed(() => ({
 | 
					    const activityLogListFilters = computed(() => ({
 | 
				
			||||||
        actor_id: {
 | 
					        actor_id: {
 | 
				
			||||||
            label: 'Actor',
 | 
					            label: t('globals.terms.actor'),
 | 
				
			||||||
            type: FIELD_TYPE.SELECT,
 | 
					            type: FIELD_TYPE.SELECT,
 | 
				
			||||||
            operators: FIELD_OPERATORS.SELECT,
 | 
					            operators: FIELD_OPERATORS.SELECT,
 | 
				
			||||||
            options: uStore.options
 | 
					            options: uStore.options
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        activity_type: {
 | 
					        activity_type: {
 | 
				
			||||||
            label: 'Activity type',
 | 
					            label: t('globals.messages.type', {
 | 
				
			||||||
 | 
					                name: t('globals.terms.activityLog')
 | 
				
			||||||
 | 
					            }),
 | 
				
			||||||
            type: FIELD_TYPE.SELECT,
 | 
					            type: FIELD_TYPE.SELECT,
 | 
				
			||||||
            operators: FIELD_OPERATORS.SELECT,
 | 
					            operators: FIELD_OPERATORS.SELECT,
 | 
				
			||||||
            options: [{
 | 
					            options: [{
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -5,7 +5,9 @@ import { useUsersStore } from '@/stores/users'
 | 
				
			|||||||
import { useTeamStore } from '@/stores/team'
 | 
					import { useTeamStore } from '@/stores/team'
 | 
				
			||||||
import { useSlaStore } from '@/stores/sla'
 | 
					import { useSlaStore } from '@/stores/sla'
 | 
				
			||||||
import { useCustomAttributeStore } from '@/stores/customAttributes'
 | 
					import { useCustomAttributeStore } from '@/stores/customAttributes'
 | 
				
			||||||
 | 
					import { useTagStore } from '@/stores/tag'
 | 
				
			||||||
import { FIELD_TYPE, FIELD_OPERATORS } from '@/constants/filterConfig'
 | 
					import { FIELD_TYPE, FIELD_OPERATORS } from '@/constants/filterConfig'
 | 
				
			||||||
 | 
					import { useI18n } from 'vue-i18n'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function useConversationFilters () {
 | 
					export function useConversationFilters () {
 | 
				
			||||||
    const cStore = useConversationStore()
 | 
					    const cStore = useConversationStore()
 | 
				
			||||||
@@ -14,6 +16,8 @@ export function useConversationFilters () {
 | 
				
			|||||||
    const tStore = useTeamStore()
 | 
					    const tStore = useTeamStore()
 | 
				
			||||||
    const slaStore = useSlaStore()
 | 
					    const slaStore = useSlaStore()
 | 
				
			||||||
    const customAttributeStore = useCustomAttributeStore()
 | 
					    const customAttributeStore = useCustomAttributeStore()
 | 
				
			||||||
 | 
					    const tagStore = useTagStore()
 | 
				
			||||||
 | 
					    const { t } = useI18n()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const customAttributeDataTypeToFieldType = {
 | 
					    const customAttributeDataTypeToFieldType = {
 | 
				
			||||||
        'text': FIELD_TYPE.TEXT,
 | 
					        'text': FIELD_TYPE.TEXT,
 | 
				
			||||||
@@ -35,34 +39,44 @@ export function useConversationFilters () {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    const conversationsListFilters = computed(() => ({
 | 
					    const conversationsListFilters = computed(() => ({
 | 
				
			||||||
        status_id: {
 | 
					        status_id: {
 | 
				
			||||||
            label: 'Status',
 | 
					            label: t('globals.terms.status'),
 | 
				
			||||||
            type: FIELD_TYPE.SELECT,
 | 
					            type: FIELD_TYPE.SELECT,
 | 
				
			||||||
            operators: FIELD_OPERATORS.SELECT,
 | 
					            operators: FIELD_OPERATORS.SELECT,
 | 
				
			||||||
            options: cStore.statusOptions
 | 
					            options: cStore.statusOptions
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        priority_id: {
 | 
					        priority_id: {
 | 
				
			||||||
            label: 'Priority',
 | 
					            label: t('globals.terms.priority'),
 | 
				
			||||||
            type: FIELD_TYPE.SELECT,
 | 
					            type: FIELD_TYPE.SELECT,
 | 
				
			||||||
            operators: FIELD_OPERATORS.SELECT,
 | 
					            operators: FIELD_OPERATORS.SELECT,
 | 
				
			||||||
            options: cStore.priorityOptions
 | 
					            options: cStore.priorityOptions
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        assigned_team_id: {
 | 
					        assigned_team_id: {
 | 
				
			||||||
            label: 'Assigned team',
 | 
					            label: t('globals.messages.assign', {
 | 
				
			||||||
 | 
					                name: t('globals.terms.team').toLowerCase()
 | 
				
			||||||
 | 
					            }),
 | 
				
			||||||
            type: FIELD_TYPE.SELECT,
 | 
					            type: FIELD_TYPE.SELECT,
 | 
				
			||||||
            operators: FIELD_OPERATORS.SELECT,
 | 
					            operators: FIELD_OPERATORS.SELECT,
 | 
				
			||||||
            options: tStore.options
 | 
					            options: tStore.options
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        assigned_user_id: {
 | 
					        assigned_user_id: {
 | 
				
			||||||
            label: 'Assigned user',
 | 
					            label: t('globals.messages.assign', {
 | 
				
			||||||
 | 
					                name: t('globals.terms.agent').toLowerCase()
 | 
				
			||||||
 | 
					            }),
 | 
				
			||||||
            type: FIELD_TYPE.SELECT,
 | 
					            type: FIELD_TYPE.SELECT,
 | 
				
			||||||
            operators: FIELD_OPERATORS.SELECT,
 | 
					            operators: FIELD_OPERATORS.SELECT,
 | 
				
			||||||
            options: uStore.options
 | 
					            options: uStore.options
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        inbox_id: {
 | 
					        inbox_id: {
 | 
				
			||||||
            label: 'Inbox',
 | 
					            label: t('globals.terms.inbox'),
 | 
				
			||||||
            type: FIELD_TYPE.SELECT,
 | 
					            type: FIELD_TYPE.SELECT,
 | 
				
			||||||
            operators: FIELD_OPERATORS.SELECT,
 | 
					            operators: FIELD_OPERATORS.SELECT,
 | 
				
			||||||
            options: iStore.options
 | 
					            options: iStore.options
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        tags: {
 | 
				
			||||||
 | 
					            label: t('globals.terms.tag', 2),
 | 
				
			||||||
 | 
					            type: FIELD_TYPE.MULTI_SELECT,
 | 
				
			||||||
 | 
					            operators: FIELD_OPERATORS.MULTI_SELECT,
 | 
				
			||||||
 | 
					            options: tagStore.tagOptions
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }))
 | 
					    }))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -85,46 +99,50 @@ export function useConversationFilters () {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    const newConversationFilters = computed(() => ({
 | 
					    const newConversationFilters = computed(() => ({
 | 
				
			||||||
        contact_email: {
 | 
					        contact_email: {
 | 
				
			||||||
            label: 'Email',
 | 
					            label: t('globals.terms.email'),
 | 
				
			||||||
            type: FIELD_TYPE.TEXT,
 | 
					            type: FIELD_TYPE.TEXT,
 | 
				
			||||||
            operators: FIELD_OPERATORS.TEXT
 | 
					            operators: FIELD_OPERATORS.TEXT
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        content: {
 | 
					        content: {
 | 
				
			||||||
            label: 'Content',
 | 
					            label: t('globals.terms.content'),
 | 
				
			||||||
            type: FIELD_TYPE.TEXT,
 | 
					            type: FIELD_TYPE.TEXT,
 | 
				
			||||||
            operators: FIELD_OPERATORS.TEXT
 | 
					            operators: FIELD_OPERATORS.TEXT
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        subject: {
 | 
					        subject: {
 | 
				
			||||||
            label: 'Subject',
 | 
					            label: t('globals.terms.subject'),
 | 
				
			||||||
            type: FIELD_TYPE.TEXT,
 | 
					            type: FIELD_TYPE.TEXT,
 | 
				
			||||||
            operators: FIELD_OPERATORS.TEXT
 | 
					            operators: FIELD_OPERATORS.TEXT
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        status: {
 | 
					        status: {
 | 
				
			||||||
            label: 'Status',
 | 
					            label: t('globals.terms.status'),
 | 
				
			||||||
            type: FIELD_TYPE.SELECT,
 | 
					            type: FIELD_TYPE.SELECT,
 | 
				
			||||||
            operators: FIELD_OPERATORS.SELECT,
 | 
					            operators: FIELD_OPERATORS.SELECT,
 | 
				
			||||||
            options: cStore.statusOptions
 | 
					            options: cStore.statusOptions
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        priority: {
 | 
					        priority: {
 | 
				
			||||||
            label: 'Priority',
 | 
					            label: t('globals.terms.priority'),
 | 
				
			||||||
            type: FIELD_TYPE.SELECT,
 | 
					            type: FIELD_TYPE.SELECT,
 | 
				
			||||||
            operators: FIELD_OPERATORS.SELECT,
 | 
					            operators: FIELD_OPERATORS.SELECT,
 | 
				
			||||||
            options: cStore.priorityOptions
 | 
					            options: cStore.priorityOptions
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        assigned_team: {
 | 
					        assigned_team: {
 | 
				
			||||||
            label: 'Assigned team',
 | 
					            label: t('globals.messages.assign', {
 | 
				
			||||||
 | 
					                name: t('globals.terms.team').toLowerCase()
 | 
				
			||||||
 | 
					            }),
 | 
				
			||||||
            type: FIELD_TYPE.SELECT,
 | 
					            type: FIELD_TYPE.SELECT,
 | 
				
			||||||
            operators: FIELD_OPERATORS.SELECT,
 | 
					            operators: FIELD_OPERATORS.SELECT,
 | 
				
			||||||
            options: tStore.options
 | 
					            options: tStore.options
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        assigned_user: {
 | 
					        assigned_user: {
 | 
				
			||||||
            label: 'Assigned agent',
 | 
					            label: t('globals.messages.assign', {
 | 
				
			||||||
 | 
					                name: t('globals.terms.agent').toLowerCase()
 | 
				
			||||||
 | 
					            }),
 | 
				
			||||||
            type: FIELD_TYPE.SELECT,
 | 
					            type: FIELD_TYPE.SELECT,
 | 
				
			||||||
            operators: FIELD_OPERATORS.SELECT,
 | 
					            operators: FIELD_OPERATORS.SELECT,
 | 
				
			||||||
            options: uStore.options
 | 
					            options: uStore.options
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        inbox: {
 | 
					        inbox: {
 | 
				
			||||||
            label: 'Inbox',
 | 
					            label: t('globals.terms.inbox'),
 | 
				
			||||||
            type: FIELD_TYPE.SELECT,
 | 
					            type: FIELD_TYPE.SELECT,
 | 
				
			||||||
            operators: FIELD_OPERATORS.SELECT,
 | 
					            operators: FIELD_OPERATORS.SELECT,
 | 
				
			||||||
            options: iStore.options
 | 
					            options: iStore.options
 | 
				
			||||||
@@ -133,51 +151,55 @@ export function useConversationFilters () {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    const conversationFilters = computed(() => ({
 | 
					    const conversationFilters = computed(() => ({
 | 
				
			||||||
        status: {
 | 
					        status: {
 | 
				
			||||||
            label: 'Status',
 | 
					            label: t('globals.terms.status'),
 | 
				
			||||||
            type: FIELD_TYPE.SELECT,
 | 
					            type: FIELD_TYPE.SELECT,
 | 
				
			||||||
            operators: FIELD_OPERATORS.SELECT,
 | 
					            operators: FIELD_OPERATORS.SELECT,
 | 
				
			||||||
            options: cStore.statusOptions
 | 
					            options: cStore.statusOptions
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        priority: {
 | 
					        priority: {
 | 
				
			||||||
            label: 'Priority',
 | 
					            label: t('globals.terms.priority'),
 | 
				
			||||||
            type: FIELD_TYPE.SELECT,
 | 
					            type: FIELD_TYPE.SELECT,
 | 
				
			||||||
            operators: FIELD_OPERATORS.SELECT,
 | 
					            operators: FIELD_OPERATORS.SELECT,
 | 
				
			||||||
            options: cStore.priorityOptions
 | 
					            options: cStore.priorityOptions
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        assigned_team: {
 | 
					        assigned_team: {
 | 
				
			||||||
            label: 'Assigned team',
 | 
					            label: t('globals.messages.assign', {
 | 
				
			||||||
 | 
					                name: t('globals.terms.team').toLowerCase()
 | 
				
			||||||
 | 
					            }),
 | 
				
			||||||
            type: FIELD_TYPE.SELECT,
 | 
					            type: FIELD_TYPE.SELECT,
 | 
				
			||||||
            operators: FIELD_OPERATORS.SELECT,
 | 
					            operators: FIELD_OPERATORS.SELECT,
 | 
				
			||||||
            options: tStore.options
 | 
					            options: tStore.options
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        assigned_user: {
 | 
					        assigned_user: {
 | 
				
			||||||
            label: 'Assigned agent',
 | 
					            label: t('globals.messages.assign', {
 | 
				
			||||||
 | 
					                name: t('globals.terms.agent').toLowerCase()
 | 
				
			||||||
 | 
					            }),
 | 
				
			||||||
            type: FIELD_TYPE.SELECT,
 | 
					            type: FIELD_TYPE.SELECT,
 | 
				
			||||||
            operators: FIELD_OPERATORS.SELECT,
 | 
					            operators: FIELD_OPERATORS.SELECT,
 | 
				
			||||||
            options: uStore.options
 | 
					            options: uStore.options
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        hours_since_created: {
 | 
					        hours_since_created: {
 | 
				
			||||||
            label: 'Hours since created',
 | 
					            label: t('globals.messages.hoursSinceCreated'),
 | 
				
			||||||
            type: FIELD_TYPE.NUMBER,
 | 
					            type: FIELD_TYPE.NUMBER,
 | 
				
			||||||
            operators: FIELD_OPERATORS.NUMBER
 | 
					            operators: FIELD_OPERATORS.NUMBER
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        hours_since_first_reply: {
 | 
					        hours_since_first_reply: {
 | 
				
			||||||
            label: 'Hours since first reply',
 | 
					            label: t('globals.messages.hoursSinceFirstReply'),
 | 
				
			||||||
            type: FIELD_TYPE.NUMBER,
 | 
					            type: FIELD_TYPE.NUMBER,
 | 
				
			||||||
            operators: FIELD_OPERATORS.NUMBER
 | 
					            operators: FIELD_OPERATORS.NUMBER
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        hours_since_last_reply: {
 | 
					        hours_since_last_reply: {
 | 
				
			||||||
            label: 'Hours since last reply',
 | 
					            label: t('globals.messages.hoursSinceLastReply'),
 | 
				
			||||||
            type: FIELD_TYPE.NUMBER,
 | 
					            type: FIELD_TYPE.NUMBER,
 | 
				
			||||||
            operators: FIELD_OPERATORS.NUMBER
 | 
					            operators: FIELD_OPERATORS.NUMBER
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        hours_since_resolved: {
 | 
					        hours_since_resolved: {
 | 
				
			||||||
            label: 'Hours since resolved',
 | 
					            label: t('globals.messages.hoursSinceResolved'),
 | 
				
			||||||
            type: FIELD_TYPE.NUMBER,
 | 
					            type: FIELD_TYPE.NUMBER,
 | 
				
			||||||
            operators: FIELD_OPERATORS.NUMBER
 | 
					            operators: FIELD_OPERATORS.NUMBER
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        inbox: {
 | 
					        inbox: {
 | 
				
			||||||
            label: 'Inbox',
 | 
					            label: t('globals.terms.inbox'),
 | 
				
			||||||
            type: FIELD_TYPE.SELECT,
 | 
					            type: FIELD_TYPE.SELECT,
 | 
				
			||||||
            operators: FIELD_OPERATORS.SELECT,
 | 
					            operators: FIELD_OPERATORS.SELECT,
 | 
				
			||||||
            options: iStore.options
 | 
					            options: iStore.options
 | 
				
			||||||
@@ -186,86 +208,122 @@ export function useConversationFilters () {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    const conversationActions = computed(() => ({
 | 
					    const conversationActions = computed(() => ({
 | 
				
			||||||
        assign_team: {
 | 
					        assign_team: {
 | 
				
			||||||
            label: 'Assign to team',
 | 
					            label: t('globals.messages.assign', {
 | 
				
			||||||
 | 
					                name: t('globals.terms.team').toLowerCase()
 | 
				
			||||||
 | 
					            }),
 | 
				
			||||||
            type: FIELD_TYPE.SELECT,
 | 
					            type: FIELD_TYPE.SELECT,
 | 
				
			||||||
            options: tStore.options
 | 
					            options: tStore.options
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        assign_user: {
 | 
					        assign_user: {
 | 
				
			||||||
            label: 'Assign to user',
 | 
					            label: t('globals.messages.assign', {
 | 
				
			||||||
 | 
					                name: t('globals.terms.agent').toLowerCase()
 | 
				
			||||||
 | 
					            }),
 | 
				
			||||||
            type: FIELD_TYPE.SELECT,
 | 
					            type: FIELD_TYPE.SELECT,
 | 
				
			||||||
            options: uStore.options
 | 
					            options: uStore.options
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        set_status: {
 | 
					        set_status: {
 | 
				
			||||||
            label: 'Set status',
 | 
					            label: t('globals.messages.set', {
 | 
				
			||||||
 | 
					                name: t('globals.terms.status').toLowerCase()
 | 
				
			||||||
 | 
					            }),
 | 
				
			||||||
            type: FIELD_TYPE.SELECT,
 | 
					            type: FIELD_TYPE.SELECT,
 | 
				
			||||||
            options: cStore.statusOptionsNoSnooze
 | 
					            options: cStore.statusOptionsNoSnooze
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        set_priority: {
 | 
					        set_priority: {
 | 
				
			||||||
            label: 'Set priority',
 | 
					            label: t('globals.messages.set', {
 | 
				
			||||||
 | 
					                name: t('globals.terms.priority').toLowerCase()
 | 
				
			||||||
 | 
					            }),
 | 
				
			||||||
            type: FIELD_TYPE.SELECT,
 | 
					            type: FIELD_TYPE.SELECT,
 | 
				
			||||||
            options: cStore.priorityOptions
 | 
					            options: cStore.priorityOptions
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        send_private_note: {
 | 
					        send_private_note: {
 | 
				
			||||||
            label: 'Send private note',
 | 
					            label: t('globals.messages.send', {
 | 
				
			||||||
 | 
					                name: t('globals.terms.privateNote').toLowerCase()
 | 
				
			||||||
 | 
					            }),
 | 
				
			||||||
            type: FIELD_TYPE.RICHTEXT
 | 
					            type: FIELD_TYPE.RICHTEXT
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        send_reply: {
 | 
					        send_reply: {
 | 
				
			||||||
            label: 'Send reply',
 | 
					            label: t('globals.messages.send', {
 | 
				
			||||||
 | 
					                name: t('globals.terms.reply').toLowerCase()
 | 
				
			||||||
 | 
					            }),
 | 
				
			||||||
            type: FIELD_TYPE.RICHTEXT
 | 
					            type: FIELD_TYPE.RICHTEXT
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        send_csat: {
 | 
					        send_csat: {
 | 
				
			||||||
            label: 'Send CSAT',
 | 
					            label: t('globals.messages.send', {
 | 
				
			||||||
 | 
					                name: t('globals.terms.csat').toLowerCase()
 | 
				
			||||||
 | 
					            }),
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        set_sla: {
 | 
					        set_sla: {
 | 
				
			||||||
            label: 'Set SLA',
 | 
					            label: t('globals.messages.set', {
 | 
				
			||||||
 | 
					                name: t('globals.terms.sla').toLowerCase()
 | 
				
			||||||
 | 
					            }),
 | 
				
			||||||
            type: FIELD_TYPE.SELECT,
 | 
					            type: FIELD_TYPE.SELECT,
 | 
				
			||||||
            options: slaStore.options
 | 
					            options: slaStore.options
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        add_tags: {
 | 
					        add_tags: {
 | 
				
			||||||
            label: 'Add tags',
 | 
					            label: t('globals.messages.add', {
 | 
				
			||||||
 | 
					                name: t('globals.terms.tag', 2).toLowerCase()
 | 
				
			||||||
 | 
					            }),
 | 
				
			||||||
            type: FIELD_TYPE.TAG
 | 
					            type: FIELD_TYPE.TAG
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        set_tags: {
 | 
					        set_tags: {
 | 
				
			||||||
            label: 'Set tags',
 | 
					            label: t('globals.messages.set', {
 | 
				
			||||||
 | 
					                name: t('globals.terms.tag', 2).toLowerCase()
 | 
				
			||||||
 | 
					            }),
 | 
				
			||||||
            type: FIELD_TYPE.TAG
 | 
					            type: FIELD_TYPE.TAG
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        remove_tags: {
 | 
					        remove_tags: {
 | 
				
			||||||
            label: 'Remove tags',
 | 
					            label: t('globals.messages.remove', {
 | 
				
			||||||
 | 
					                name: t('globals.terms.tag', 2).toLowerCase()
 | 
				
			||||||
 | 
					            }),
 | 
				
			||||||
            type: FIELD_TYPE.TAG
 | 
					            type: FIELD_TYPE.TAG
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }))
 | 
					    }))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const macroActions = computed(() => ({
 | 
					    const macroActions = computed(() => ({
 | 
				
			||||||
        assign_team: {
 | 
					        assign_team: {
 | 
				
			||||||
            label: 'Assign to team',
 | 
					            label: t('globals.messages.assign', {
 | 
				
			||||||
 | 
					                name: t('globals.terms.team').toLowerCase()
 | 
				
			||||||
 | 
					            }),
 | 
				
			||||||
            type: FIELD_TYPE.SELECT,
 | 
					            type: FIELD_TYPE.SELECT,
 | 
				
			||||||
            options: tStore.options
 | 
					            options: tStore.options
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        assign_user: {
 | 
					        assign_user: {
 | 
				
			||||||
            label: 'Assign to user',
 | 
					            label: t('globals.messages.assign', {
 | 
				
			||||||
 | 
					                name: t('globals.terms.agent').toLowerCase()
 | 
				
			||||||
 | 
					            }),
 | 
				
			||||||
            type: FIELD_TYPE.SELECT,
 | 
					            type: FIELD_TYPE.SELECT,
 | 
				
			||||||
            options: uStore.options
 | 
					            options: uStore.options
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        set_status: {
 | 
					        set_status: {
 | 
				
			||||||
            label: 'Set status',
 | 
					            label: t('globals.messages.set', {
 | 
				
			||||||
 | 
					                name: t('globals.terms.status').toLowerCase()
 | 
				
			||||||
 | 
					            }),
 | 
				
			||||||
            type: FIELD_TYPE.SELECT,
 | 
					            type: FIELD_TYPE.SELECT,
 | 
				
			||||||
            options: cStore.statusOptionsNoSnooze
 | 
					            options: cStore.statusOptionsNoSnooze
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        set_priority: {
 | 
					        set_priority: {
 | 
				
			||||||
            label: 'Set priority',
 | 
					            label: t('globals.messages.set', {
 | 
				
			||||||
 | 
					                name: t('globals.terms.priority').toLowerCase()
 | 
				
			||||||
 | 
					            }),
 | 
				
			||||||
            type: FIELD_TYPE.SELECT,
 | 
					            type: FIELD_TYPE.SELECT,
 | 
				
			||||||
            options: cStore.priorityOptions
 | 
					            options: cStore.priorityOptions
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        add_tags: {
 | 
					        add_tags: {
 | 
				
			||||||
            label: 'Add tags',
 | 
					            label: t('globals.messages.add', {
 | 
				
			||||||
 | 
					                name: t('globals.terms.tag', 2).toLowerCase()
 | 
				
			||||||
 | 
					            }),
 | 
				
			||||||
            type: FIELD_TYPE.TAG
 | 
					            type: FIELD_TYPE.TAG
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        set_tags: {
 | 
					        set_tags: {
 | 
				
			||||||
            label: 'Set tags',
 | 
					            label: t('globals.messages.set', {
 | 
				
			||||||
 | 
					                name: t('globals.terms.tag', 2).toLowerCase()
 | 
				
			||||||
 | 
					            }),
 | 
				
			||||||
            type: FIELD_TYPE.TAG
 | 
					            type: FIELD_TYPE.TAG
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        remove_tags: {
 | 
					        remove_tags: {
 | 
				
			||||||
            label: 'Remove tags',
 | 
					            label: t('globals.messages.remove', {
 | 
				
			||||||
 | 
					                name: t('globals.terms.tag', 2).toLowerCase()
 | 
				
			||||||
 | 
					            }),
 | 
				
			||||||
            type: FIELD_TYPE.TAG
 | 
					            type: FIELD_TYPE.TAG
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }))
 | 
					    }))
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										142
									
								
								frontend/src/composables/useFileUpload.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										142
									
								
								frontend/src/composables/useFileUpload.js
									
									
									
									
									
										Normal 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
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -1,6 +1,7 @@
 | 
				
			|||||||
export const FIELD_TYPE = {
 | 
					export const FIELD_TYPE = {
 | 
				
			||||||
    SELECT: 'select',
 | 
					    SELECT: 'select',
 | 
				
			||||||
    TAG: 'tag',
 | 
					    TAG: 'tag',
 | 
				
			||||||
 | 
					    MULTI_SELECT: 'multi-select',
 | 
				
			||||||
    TEXT: 'text',
 | 
					    TEXT: 'text',
 | 
				
			||||||
    NUMBER: 'number',
 | 
					    NUMBER: 'number',
 | 
				
			||||||
    RICHTEXT: 'richtext',
 | 
					    RICHTEXT: 'richtext',
 | 
				
			||||||
@@ -39,4 +40,5 @@ export const FIELD_OPERATORS = {
 | 
				
			|||||||
        OPERATOR.LESS_THAN
 | 
					        OPERATOR.LESS_THAN
 | 
				
			||||||
    ],
 | 
					    ],
 | 
				
			||||||
    NUMBER: [OPERATOR.EQUALS, OPERATOR.NOT_EQUALS, OPERATOR.GREATER_THAN, OPERATOR.LESS_THAN],
 | 
					    NUMBER: [OPERATOR.EQUALS, OPERATOR.NOT_EQUALS, OPERATOR.GREATER_THAN, OPERATOR.LESS_THAN],
 | 
				
			||||||
 | 
					    MULTI_SELECT: [OPERATOR.CONTAINS, OPERATOR.NOT_CONTAINS, OPERATOR.SET, OPERATOR.NOT_SET]
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,150 +1,160 @@
 | 
				
			|||||||
export const reportsNavItems = [
 | 
					export const reportsNavItems = [
 | 
				
			||||||
    {
 | 
					  {
 | 
				
			||||||
        titleKey: 'navigation.overview',
 | 
					    titleKey: 'globals.terms.overview',
 | 
				
			||||||
        href: '/reports/overview',
 | 
					    href: '/reports/overview',
 | 
				
			||||||
        permission: 'reports:manage'
 | 
					    permission: 'reports:manage'
 | 
				
			||||||
    }
 | 
					  }
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const adminNavItems = [
 | 
					export const adminNavItems = [
 | 
				
			||||||
    {
 | 
					  {
 | 
				
			||||||
        titleKey: 'navigation.workspace',
 | 
					    titleKey: 'globals.terms.workspace',
 | 
				
			||||||
        children: [
 | 
					    children: [
 | 
				
			||||||
            {
 | 
					      {
 | 
				
			||||||
                titleKey: 'navigation.generalSettings',
 | 
					        titleKey: 'globals.terms.general',
 | 
				
			||||||
                href: '/admin/general',
 | 
					        href: '/admin/general',
 | 
				
			||||||
                permission: 'general_settings:manage'
 | 
					        permission: 'general_settings:manage'
 | 
				
			||||||
            },
 | 
					      },
 | 
				
			||||||
            {
 | 
					      {
 | 
				
			||||||
                titleKey: 'navigation.businessHours',
 | 
					        titleKey: 'globals.terms.businessHour',
 | 
				
			||||||
                href: '/admin/business-hours',
 | 
					        href: '/admin/business-hours',
 | 
				
			||||||
                permission: 'business_hours:manage'
 | 
					        permission: 'business_hours:manage'
 | 
				
			||||||
            },
 | 
					      },
 | 
				
			||||||
            {
 | 
					      {
 | 
				
			||||||
                titleKey: 'navigation.slaPolicies',
 | 
					        titleKey: 'globals.terms.slaPolicy',
 | 
				
			||||||
                href: '/admin/sla',
 | 
					        href: '/admin/sla',
 | 
				
			||||||
                permission: 'sla:manage'
 | 
					        permission: 'sla:manage'
 | 
				
			||||||
            }
 | 
					      }
 | 
				
			||||||
        ]
 | 
					    ]
 | 
				
			||||||
    },
 | 
					  },
 | 
				
			||||||
    {
 | 
					  {
 | 
				
			||||||
        titleKey: 'navigation.conversations',
 | 
					    titleKey: 'globals.terms.conversation',
 | 
				
			||||||
        children: [
 | 
					    children: [
 | 
				
			||||||
            {
 | 
					      {
 | 
				
			||||||
                titleKey: 'navigation.tags',
 | 
					        titleKey: 'globals.terms.tag',
 | 
				
			||||||
                href: '/admin/conversations/tags',
 | 
					        href: '/admin/conversations/tags',
 | 
				
			||||||
                permission: 'tags:manage'
 | 
					        permission: 'tags:manage'
 | 
				
			||||||
            },
 | 
					      },
 | 
				
			||||||
            {
 | 
					      {
 | 
				
			||||||
                titleKey: 'navigation.macros',
 | 
					        titleKey: 'globals.terms.macro',
 | 
				
			||||||
                href: '/admin/conversations/macros',
 | 
					        href: '/admin/conversations/macros',
 | 
				
			||||||
                permission: 'macros:manage'
 | 
					        permission: 'macros:manage'
 | 
				
			||||||
            },
 | 
					      },
 | 
				
			||||||
            {
 | 
					      {
 | 
				
			||||||
                titleKey: 'navigation.statuses',
 | 
					        titleKey: 'globals.terms.status',
 | 
				
			||||||
                href: '/admin/conversations/statuses',
 | 
					        href: '/admin/conversations/statuses',
 | 
				
			||||||
                permission: 'status:manage'
 | 
					        permission: 'status:manage'
 | 
				
			||||||
            }
 | 
					      }
 | 
				
			||||||
        ]
 | 
					    ]
 | 
				
			||||||
    },
 | 
					  },
 | 
				
			||||||
    {
 | 
					  {
 | 
				
			||||||
        titleKey: 'navigation.inboxes',
 | 
					    titleKey: 'globals.terms.inbox',
 | 
				
			||||||
        children: [
 | 
					    children: [
 | 
				
			||||||
            {
 | 
					      {
 | 
				
			||||||
                titleKey: 'navigation.inboxes',
 | 
					        titleKey: 'globals.terms.inbox',
 | 
				
			||||||
                href: '/admin/inboxes',
 | 
					        href: '/admin/inboxes',
 | 
				
			||||||
                permission: 'inboxes:manage'
 | 
					        permission: 'inboxes:manage'
 | 
				
			||||||
            }
 | 
					      }
 | 
				
			||||||
        ]
 | 
					    ]
 | 
				
			||||||
    },
 | 
					  },
 | 
				
			||||||
    {
 | 
					  {
 | 
				
			||||||
        titleKey: 'navigation.teammates',
 | 
					    titleKey: 'globals.terms.teammate',
 | 
				
			||||||
        children: [
 | 
					    children: [
 | 
				
			||||||
            {
 | 
					      {
 | 
				
			||||||
                titleKey: 'navigation.agents',
 | 
					        titleKey: 'globals.terms.agent',
 | 
				
			||||||
                href: '/admin/teams/agents',
 | 
					        href: '/admin/teams/agents',
 | 
				
			||||||
                permission: 'users:manage'
 | 
					        permission: 'users:manage'
 | 
				
			||||||
            },
 | 
					      },
 | 
				
			||||||
            {
 | 
					      {
 | 
				
			||||||
                titleKey: 'navigation.teams',
 | 
					        titleKey: 'globals.terms.team',
 | 
				
			||||||
                href: '/admin/teams/teams',
 | 
					        href: '/admin/teams/teams',
 | 
				
			||||||
                permission: 'teams:manage'
 | 
					        permission: 'teams:manage'
 | 
				
			||||||
            },
 | 
					      },
 | 
				
			||||||
            {
 | 
					      {
 | 
				
			||||||
                titleKey: 'navigation.roles',
 | 
					        titleKey: 'globals.terms.role',
 | 
				
			||||||
                href: '/admin/teams/roles',
 | 
					        href: '/admin/teams/roles',
 | 
				
			||||||
                permission: 'roles:manage'
 | 
					        permission: 'roles:manage'
 | 
				
			||||||
            },
 | 
					      },
 | 
				
			||||||
            {
 | 
					      {
 | 
				
			||||||
                titleKey: 'navigation.activityLog',
 | 
					        titleKey: 'globals.terms.activityLog',
 | 
				
			||||||
                href: '/admin/teams/activity-log',
 | 
					        href: '/admin/teams/activity-log',
 | 
				
			||||||
                permission: 'activity_logs:manage'
 | 
					        permission: 'activity_logs:manage'
 | 
				
			||||||
            }
 | 
					      }
 | 
				
			||||||
        ]
 | 
					    ]
 | 
				
			||||||
    },
 | 
					  },
 | 
				
			||||||
    {
 | 
					  {
 | 
				
			||||||
        titleKey: 'navigation.automations',
 | 
					    titleKey: 'globals.terms.automation',
 | 
				
			||||||
        children: [
 | 
					    children: [
 | 
				
			||||||
            {
 | 
					      {
 | 
				
			||||||
                titleKey: 'navigation.automations',
 | 
					        titleKey: 'globals.terms.automation',
 | 
				
			||||||
                href: '/admin/automations',
 | 
					        href: '/admin/automations',
 | 
				
			||||||
                permission: 'automations:manage'
 | 
					        permission: 'automations:manage'
 | 
				
			||||||
            }
 | 
					      }
 | 
				
			||||||
        ]
 | 
					    ]
 | 
				
			||||||
    },
 | 
					  },
 | 
				
			||||||
    {
 | 
					  {
 | 
				
			||||||
        titleKey: 'navigation.customAttributes',
 | 
					    titleKey: 'globals.terms.customAttribute',
 | 
				
			||||||
        children: [
 | 
					    children: [
 | 
				
			||||||
            {
 | 
					      {
 | 
				
			||||||
                titleKey: 'navigation.customAttributes',
 | 
					        titleKey: 'globals.terms.customAttribute',
 | 
				
			||||||
                href: '/admin/custom-attributes',
 | 
					        href: '/admin/custom-attributes',
 | 
				
			||||||
                permission: 'custom_attributes:manage'
 | 
					        permission: 'custom_attributes:manage'
 | 
				
			||||||
            }
 | 
					      }
 | 
				
			||||||
        ]
 | 
					    ]
 | 
				
			||||||
    },
 | 
					  },
 | 
				
			||||||
    {
 | 
					  {
 | 
				
			||||||
        titleKey: 'navigation.notifications',
 | 
					    titleKey: 'globals.terms.notification',
 | 
				
			||||||
        children: [
 | 
					    children: [
 | 
				
			||||||
            {
 | 
					      {
 | 
				
			||||||
                titleKey: 'navigation.email',
 | 
					        titleKey: 'globals.terms.email',
 | 
				
			||||||
                href: '/admin/notification',
 | 
					        href: '/admin/notification',
 | 
				
			||||||
                permission: 'notification_settings:manage'
 | 
					        permission: 'notification_settings:manage'
 | 
				
			||||||
            }
 | 
					      }
 | 
				
			||||||
        ]
 | 
					    ]
 | 
				
			||||||
    },
 | 
					  },
 | 
				
			||||||
    {
 | 
					  {
 | 
				
			||||||
        titleKey: 'navigation.templates',
 | 
					    titleKey: 'globals.terms.template',
 | 
				
			||||||
        children: [
 | 
					    children: [
 | 
				
			||||||
            {
 | 
					      {
 | 
				
			||||||
                titleKey: 'navigation.templates',
 | 
					        titleKey: 'globals.terms.template',
 | 
				
			||||||
                href: '/admin/templates',
 | 
					        href: '/admin/templates',
 | 
				
			||||||
                permission: 'templates:manage'
 | 
					        permission: 'templates:manage'
 | 
				
			||||||
            }
 | 
					      }
 | 
				
			||||||
        ]
 | 
					    ]
 | 
				
			||||||
    },
 | 
					  },
 | 
				
			||||||
    {
 | 
					  {
 | 
				
			||||||
        titleKey: 'navigation.security',
 | 
					    titleKey: 'globals.terms.security',
 | 
				
			||||||
        children: [
 | 
					    children: [
 | 
				
			||||||
            {
 | 
					      {
 | 
				
			||||||
                titleKey: 'navigation.sso',
 | 
					        titleKey: 'globals.terms.sso',
 | 
				
			||||||
                href: '/admin/sso',
 | 
					        href: '/admin/sso',
 | 
				
			||||||
                permission: 'oidc:manage'
 | 
					        permission: 'oidc:manage'
 | 
				
			||||||
            }
 | 
					      }
 | 
				
			||||||
        ]
 | 
					    ]
 | 
				
			||||||
    },
 | 
					  },
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    titleKey: 'globals.terms.integration',
 | 
				
			||||||
 | 
					    isTitleKeyPlural: true,
 | 
				
			||||||
 | 
					    children: [
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        titleKey: 'globals.terms.webhook',
 | 
				
			||||||
 | 
					        href: '/admin/webhooks',
 | 
				
			||||||
 | 
					        permission: 'webhooks:manage'
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const accountNavItems = [
 | 
					export const accountNavItems = [
 | 
				
			||||||
    {
 | 
					  {
 | 
				
			||||||
        titleKey: 'navigation.profile',
 | 
					    titleKey: 'globals.terms.profile',
 | 
				
			||||||
        href: '/account/profile',
 | 
					    href: '/account/profile'
 | 
				
			||||||
        description: 'Update your profile'
 | 
					  }
 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const contactNavItems = [
 | 
					export const contactNavItems = [
 | 
				
			||||||
    {
 | 
					  {
 | 
				
			||||||
        titleKey: 'navigation.allContacts',
 | 
					    titleKey: 'globals.terms.contact',
 | 
				
			||||||
        href: '/contacts',
 | 
					    href: '/contacts'
 | 
				
			||||||
    }
 | 
					  }
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,41 +1,43 @@
 | 
				
			|||||||
export const permissions = {
 | 
					export const permissions = {
 | 
				
			||||||
    CONVERSATIONS_READ: 'conversations:read',
 | 
					  CONVERSATIONS_READ: 'conversations:read',
 | 
				
			||||||
    CONVERSATIONS_WRITE: 'conversations:write',
 | 
					  CONVERSATIONS_WRITE: 'conversations:write',
 | 
				
			||||||
    CONVERSATIONS_READ_ASSIGNED: 'conversations:read_assigned',
 | 
					  CONVERSATIONS_READ_ASSIGNED: 'conversations:read_assigned',
 | 
				
			||||||
    CONVERSATIONS_READ_ALL: 'conversations:read_all',
 | 
					  CONVERSATIONS_READ_ALL: 'conversations:read_all',
 | 
				
			||||||
    CONVERSATIONS_READ_UNASSIGNED: 'conversations:read_unassigned',
 | 
					  CONVERSATIONS_READ_UNASSIGNED: 'conversations:read_unassigned',
 | 
				
			||||||
    CONVERSATIONS_READ_TEAM_INBOX: 'conversations:read_team_inbox',
 | 
					  CONVERSATIONS_READ_TEAM_INBOX: 'conversations:read_team_inbox',
 | 
				
			||||||
    CONVERSATIONS_UPDATE_USER_ASSIGNEE: 'conversations:update_user_assignee',
 | 
					  CONVERSATIONS_UPDATE_USER_ASSIGNEE: 'conversations:update_user_assignee',
 | 
				
			||||||
    CONVERSATIONS_UPDATE_TEAM_ASSIGNEE: 'conversations:update_team_assignee',
 | 
					  CONVERSATIONS_UPDATE_TEAM_ASSIGNEE: 'conversations:update_team_assignee',
 | 
				
			||||||
    CONVERSATIONS_UPDATE_PRIORITY: 'conversations:update_priority',
 | 
					  CONVERSATIONS_UPDATE_PRIORITY: 'conversations:update_priority',
 | 
				
			||||||
    CONVERSATIONS_UPDATE_STATUS: 'conversations:update_status',
 | 
					  CONVERSATIONS_UPDATE_STATUS: 'conversations:update_status',
 | 
				
			||||||
    CONVERSATIONS_UPDATE_TAGS: 'conversations:update_tags',
 | 
					  CONVERSATIONS_UPDATE_TAGS: 'conversations:update_tags',
 | 
				
			||||||
    MESSAGES_READ: 'messages:read',
 | 
					  MESSAGES_READ: 'messages:read',
 | 
				
			||||||
    MESSAGES_WRITE: 'messages:write',
 | 
					  MESSAGES_WRITE: 'messages:write',
 | 
				
			||||||
    VIEW_MANAGE: 'view:manage',
 | 
					  MESSAGES_WRITE_AS_CONTACT: 'messages:write_as_contact',
 | 
				
			||||||
    GENERAL_SETTINGS_MANAGE: 'general_settings:manage',
 | 
					  VIEW_MANAGE: 'view:manage',
 | 
				
			||||||
    NOTIFICATION_SETTINGS_MANAGE: 'notification_settings:manage',
 | 
					  GENERAL_SETTINGS_MANAGE: 'general_settings:manage',
 | 
				
			||||||
    STATUS_MANAGE: 'status:manage',
 | 
					  NOTIFICATION_SETTINGS_MANAGE: 'notification_settings:manage',
 | 
				
			||||||
    OIDC_MANAGE: 'oidc:manage',
 | 
					  STATUS_MANAGE: 'status:manage',
 | 
				
			||||||
    TAGS_MANAGE: 'tags:manage',
 | 
					  OIDC_MANAGE: 'oidc:manage',
 | 
				
			||||||
    MACROS_MANAGE: 'macros:manage',
 | 
					  TAGS_MANAGE: 'tags:manage',
 | 
				
			||||||
    USERS_MANAGE: 'users:manage',
 | 
					  MACROS_MANAGE: 'macros:manage',
 | 
				
			||||||
    TEAMS_MANAGE: 'teams:manage',
 | 
					  USERS_MANAGE: 'users:manage',
 | 
				
			||||||
    AUTOMATIONS_MANAGE: 'automations:manage',
 | 
					  TEAMS_MANAGE: 'teams:manage',
 | 
				
			||||||
    INBOXES_MANAGE: 'inboxes:manage',
 | 
					  AUTOMATIONS_MANAGE: 'automations:manage',
 | 
				
			||||||
    ROLES_MANAGE: 'roles:manage',
 | 
					  INBOXES_MANAGE: 'inboxes:manage',
 | 
				
			||||||
    TEMPLATES_MANAGE: 'templates:manage',
 | 
					  ROLES_MANAGE: 'roles:manage',
 | 
				
			||||||
    REPORTS_MANAGE: 'reports:manage',
 | 
					  TEMPLATES_MANAGE: 'templates:manage',
 | 
				
			||||||
    BUSINESS_HOURS_MANAGE: 'business_hours:manage',
 | 
					  REPORTS_MANAGE: 'reports:manage',
 | 
				
			||||||
    SLA_MANAGE: 'sla:manage',
 | 
					  BUSINESS_HOURS_MANAGE: 'business_hours:manage',
 | 
				
			||||||
    AI_MANAGE: 'ai:manage',
 | 
					  SLA_MANAGE: 'sla:manage',
 | 
				
			||||||
    CUSTOM_ATTRIBUTES_MANAGE: 'custom_attributes:manage',
 | 
					  AI_MANAGE: 'ai:manage',
 | 
				
			||||||
    CONTACTS_READ_ALL: 'contacts:read_all',
 | 
					  CUSTOM_ATTRIBUTES_MANAGE: 'custom_attributes:manage',
 | 
				
			||||||
    CONTACTS_READ: 'contacts:read',
 | 
					  CONTACTS_READ_ALL: 'contacts:read_all',
 | 
				
			||||||
    CONTACTS_WRITE: 'contacts:write',
 | 
					  CONTACTS_READ: 'contacts:read',
 | 
				
			||||||
    CONTACTS_BLOCK: 'contacts:block',
 | 
					  CONTACTS_WRITE: 'contacts:write',
 | 
				
			||||||
    CONTACT_NOTES_READ: 'contact_notes:read',
 | 
					  CONTACTS_BLOCK: 'contacts:block',
 | 
				
			||||||
    CONTACT_NOTES_WRITE: 'contact_notes:write',
 | 
					  CONTACT_NOTES_READ: 'contact_notes:read',
 | 
				
			||||||
    CONTACT_NOTES_DELETE: 'contact_notes:delete',
 | 
					  CONTACT_NOTES_WRITE: 'contact_notes:write',
 | 
				
			||||||
    ACTIVITY_LOGS_MANAGE: 'activity_logs:manage',
 | 
					  CONTACT_NOTES_DELETE: 'contact_notes:delete',
 | 
				
			||||||
};
 | 
					  ACTIVITY_LOGS_MANAGE: 'activity_logs:manage',
 | 
				
			||||||
 | 
					  WEBHOOKS_MANAGE: 'webhooks:manage'
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1 +1,3 @@
 | 
				
			|||||||
export const Roles = ["Admin", "Agent"]
 | 
					export const Roles = ["Admin", "Agent"]
 | 
				
			||||||
 | 
					export const UserTypeAgent = "agent"
 | 
				
			||||||
 | 
					export const UserTypeContact = "contact"
 | 
				
			||||||
@@ -44,7 +44,7 @@
 | 
				
			|||||||
              </SelectTrigger>
 | 
					              </SelectTrigger>
 | 
				
			||||||
              <SelectContent>
 | 
					              <SelectContent>
 | 
				
			||||||
                <SelectItem :value="'activity_logs.created_at'">
 | 
					                <SelectItem :value="'activity_logs.created_at'">
 | 
				
			||||||
                  {{ t('form.field.createdAt') }}
 | 
					                  {{ t('globals.terms.createdAt') }}
 | 
				
			||||||
                </SelectItem>
 | 
					                </SelectItem>
 | 
				
			||||||
              </SelectContent>
 | 
					              </SelectContent>
 | 
				
			||||||
            </Select>
 | 
					            </Select>
 | 
				
			||||||
@@ -63,35 +63,20 @@
 | 
				
			|||||||
        </Popover>
 | 
					        </Popover>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <div v-if="loading" class="w-full">
 | 
					      <div class="w-full overflow-x-auto">
 | 
				
			||||||
        <div class="flex border-b border-border p-4 font-medium bg-gray-50">
 | 
					        <SimpleTable
 | 
				
			||||||
          <div class="flex-1 text-muted-foreground">{{ t('form.field.name') }}</div>
 | 
					          :headers="[
 | 
				
			||||||
          <div class="w-[200px] text-muted-foreground">{{ t('form.field.date') }}</div>
 | 
					            t('globals.terms.name'),
 | 
				
			||||||
          <div class="w-[150px] text-muted-foreground">{{ t('globals.terms.ipAddress') }}</div>
 | 
					            t('globals.terms.timestamp'),
 | 
				
			||||||
        </div>
 | 
					            t('globals.terms.ipAddress')
 | 
				
			||||||
        <div v-for="i in perPage" :key="i" class="flex border-b border-border py-3 px-4">
 | 
					          ]"
 | 
				
			||||||
          <div class="flex-1">
 | 
					          :keys="['activity_description', 'created_at', 'ip']"
 | 
				
			||||||
            <Skeleton class="h-4 w-[90%]" />
 | 
					          :data="activityLogs"
 | 
				
			||||||
          </div>
 | 
					          :showDelete="false"
 | 
				
			||||||
          <div class="w-[200px]">
 | 
					          :loading="loading"
 | 
				
			||||||
            <Skeleton class="h-4 w-[120px]" />
 | 
					          :skeletonRows="15"
 | 
				
			||||||
          </div>
 | 
					        />
 | 
				
			||||||
          <div class="w-[150px]">
 | 
					 | 
				
			||||||
            <Skeleton class="h-4 w-[100px]" />
 | 
					 | 
				
			||||||
          </div>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
      </div>
 | 
					      </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>
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <!-- TODO: deduplicate this code, copied from contacts list -->
 | 
					    <!-- TODO: deduplicate this code, copied from contacts list -->
 | 
				
			||||||
@@ -163,7 +148,6 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { ref, computed, onMounted, watch } from 'vue'
 | 
					import { ref, computed, onMounted, watch } from 'vue'
 | 
				
			||||||
import { Skeleton } from '@/components/ui/skeleton'
 | 
					 | 
				
			||||||
import SimpleTable from '@/components/table/SimpleTable.vue'
 | 
					import SimpleTable from '@/components/table/SimpleTable.vue'
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  Pagination,
 | 
					  Pagination,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -24,7 +24,7 @@
 | 
				
			|||||||
            <div class="flex items-center gap-2">
 | 
					            <div class="flex items-center gap-2">
 | 
				
			||||||
              <Clock class="w-5 h-5 text-gray-400" />
 | 
					              <Clock class="w-5 h-5 text-gray-400" />
 | 
				
			||||||
              <div>
 | 
					              <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">
 | 
					                <p class="text-sm font-medium text-gray-700 dark:text-foreground">
 | 
				
			||||||
                  {{
 | 
					                  {{
 | 
				
			||||||
                    props.initialValues.last_active_at
 | 
					                    props.initialValues.last_active_at
 | 
				
			||||||
@@ -37,7 +37,7 @@
 | 
				
			|||||||
            <div class="flex items-center gap-2">
 | 
					            <div class="flex items-center gap-2">
 | 
				
			||||||
              <LogIn class="w-5 h-5 text-gray-400" />
 | 
					              <LogIn class="w-5 h-5 text-gray-400" />
 | 
				
			||||||
              <div>
 | 
					              <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">
 | 
					                <p class="text-sm font-medium text-gray-700 dark:text-foreground">
 | 
				
			||||||
                  {{
 | 
					                  {{
 | 
				
			||||||
                    props.initialValues.last_login_at
 | 
					                    props.initialValues.last_login_at
 | 
				
			||||||
@@ -52,10 +52,128 @@
 | 
				
			|||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <!-- API Key Management Section -->
 | 
				
			||||||
 | 
					    <div class="bg-muted/30 box p-4 space-y-4" v-if="!isNewForm">
 | 
				
			||||||
 | 
					      <!-- Header -->
 | 
				
			||||||
 | 
					      <div class="flex items-center justify-between">
 | 
				
			||||||
 | 
					        <div>
 | 
				
			||||||
 | 
					          <p class="text-base font-semibold text-gray-900 dark:text-foreground">
 | 
				
			||||||
 | 
					            {{ $t('globals.terms.apiKey', 2) }}
 | 
				
			||||||
 | 
					          </p>
 | 
				
			||||||
 | 
					          <p class="text-sm text-gray-500">
 | 
				
			||||||
 | 
					            {{ $t('admin.agent.apiKey.description') }}
 | 
				
			||||||
 | 
					          </p>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <!-- API Key Display -->
 | 
				
			||||||
 | 
					      <div v-if="apiKeyData.api_key" class="space-y-3">
 | 
				
			||||||
 | 
					        <div class="flex items-center justify-between p-3 bg-background border rounded-md">
 | 
				
			||||||
 | 
					          <div class="flex items-center gap-3">
 | 
				
			||||||
 | 
					            <Key class="w-4 h-4 text-gray-400" />
 | 
				
			||||||
 | 
					            <div>
 | 
				
			||||||
 | 
					              <p class="text-sm font-medium">{{ $t('globals.terms.apiKey') }}</p>
 | 
				
			||||||
 | 
					              <p class="text-xs text-gray-500 font-mono">{{ apiKeyData.api_key }}</p>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					          <div class="flex gap-2">
 | 
				
			||||||
 | 
					            <Button
 | 
				
			||||||
 | 
					              type="button"
 | 
				
			||||||
 | 
					              variant="outline"
 | 
				
			||||||
 | 
					              size="sm"
 | 
				
			||||||
 | 
					              @click="regenerateAPIKey"
 | 
				
			||||||
 | 
					              :disabled="isAPIKeyLoading"
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					              <RotateCcw class="w-4 h-4 mr-1" />
 | 
				
			||||||
 | 
					              {{ $t('globals.messages.regenerate') }}
 | 
				
			||||||
 | 
					            </Button>
 | 
				
			||||||
 | 
					            <Button
 | 
				
			||||||
 | 
					              type="button"
 | 
				
			||||||
 | 
					              variant="destructive"
 | 
				
			||||||
 | 
					              size="sm"
 | 
				
			||||||
 | 
					              @click="revokeAPIKey"
 | 
				
			||||||
 | 
					              :disabled="isAPIKeyLoading"
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					              <Trash2 class="w-4 h-4 mr-1" />
 | 
				
			||||||
 | 
					              {{ $t('globals.messages.revoke') }}
 | 
				
			||||||
 | 
					            </Button>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <!-- Last Used Info -->
 | 
				
			||||||
 | 
					        <div v-if="apiKeyLastUsedAt" class="text-xs text-gray-500">
 | 
				
			||||||
 | 
					          {{ $t('globals.messages.lastUsed') }}:
 | 
				
			||||||
 | 
					          {{ format(new Date(apiKeyLastUsedAt), 'PPpp') }}
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <!-- No API Key State -->
 | 
				
			||||||
 | 
					      <div v-else class="text-center py-6">
 | 
				
			||||||
 | 
					        <Key class="w-8 h-8 text-gray-400 mx-auto mb-2" />
 | 
				
			||||||
 | 
					        <p class="text-sm text-gray-500 mb-3">{{ $t('admin.agent.apiKey.noKey') }}</p>
 | 
				
			||||||
 | 
					        <Button type="button" @click="generateAPIKey" :disabled="isAPIKeyLoading">
 | 
				
			||||||
 | 
					          <Plus class="w-4 h-4 mr-1" />
 | 
				
			||||||
 | 
					          {{ $t('globals.messages.generate', { name: $t('globals.terms.apiKey') }) }}
 | 
				
			||||||
 | 
					        </Button>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <!-- API Key Display Dialog -->
 | 
				
			||||||
 | 
					    <Dialog v-model:open="showAPIKeyDialog">
 | 
				
			||||||
 | 
					      <DialogContent class="sm:max-w-md">
 | 
				
			||||||
 | 
					        <DialogHeader>
 | 
				
			||||||
 | 
					          <DialogTitle>
 | 
				
			||||||
 | 
					            {{ $t('globals.messages.generated', { name: $t('globals.terms.apiKey') }) }}
 | 
				
			||||||
 | 
					          </DialogTitle>
 | 
				
			||||||
 | 
					          <DialogDescription> </DialogDescription>
 | 
				
			||||||
 | 
					        </DialogHeader>
 | 
				
			||||||
 | 
					        <div class="space-y-4">
 | 
				
			||||||
 | 
					          <div>
 | 
				
			||||||
 | 
					            <Label class="text-sm font-medium">{{ $t('globals.terms.apiKey') }}</Label>
 | 
				
			||||||
 | 
					            <div class="flex items-center gap-2 mt-1">
 | 
				
			||||||
 | 
					              <Input v-model="newAPIKeyData.api_key" readonly class="font-mono text-sm" />
 | 
				
			||||||
 | 
					              <Button
 | 
				
			||||||
 | 
					                type="button"
 | 
				
			||||||
 | 
					                variant="outline"
 | 
				
			||||||
 | 
					                size="sm"
 | 
				
			||||||
 | 
					                @click="copyToClipboard(newAPIKeyData.api_key)"
 | 
				
			||||||
 | 
					              >
 | 
				
			||||||
 | 
					                <Copy class="w-4 h-4" />
 | 
				
			||||||
 | 
					              </Button>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					          <div>
 | 
				
			||||||
 | 
					            <Label class="text-sm font-medium">{{ $t('globals.terms.secret') }}</Label>
 | 
				
			||||||
 | 
					            <div class="flex items-center gap-2 mt-1">
 | 
				
			||||||
 | 
					              <Input v-model="newAPIKeyData.api_secret" readonly class="font-mono text-sm" />
 | 
				
			||||||
 | 
					              <Button
 | 
				
			||||||
 | 
					                type="button"
 | 
				
			||||||
 | 
					                variant="outline"
 | 
				
			||||||
 | 
					                size="sm"
 | 
				
			||||||
 | 
					                @click="copyToClipboard(newAPIKeyData.api_secret)"
 | 
				
			||||||
 | 
					              >
 | 
				
			||||||
 | 
					                <Copy class="w-4 h-4" />
 | 
				
			||||||
 | 
					              </Button>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					          <Alert>
 | 
				
			||||||
 | 
					            <AlertTriangle class="h-4 w-4" />
 | 
				
			||||||
 | 
					            <AlertTitle>{{ $t('globals.terms.warning') }}</AlertTitle>
 | 
				
			||||||
 | 
					            <AlertDescription>
 | 
				
			||||||
 | 
					              {{ $t('admin.agent.apiKey.warningMessage') }}
 | 
				
			||||||
 | 
					            </AlertDescription>
 | 
				
			||||||
 | 
					          </Alert>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        <DialogFooter>
 | 
				
			||||||
 | 
					          <Button @click="closeAPIKeyModal">{{ $t('globals.messages.close') }}</Button>
 | 
				
			||||||
 | 
					        </DialogFooter>
 | 
				
			||||||
 | 
					      </DialogContent>
 | 
				
			||||||
 | 
					    </Dialog>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <!-- Form Fields -->
 | 
					    <!-- Form Fields -->
 | 
				
			||||||
    <FormField v-slot="{ field }" name="first_name">
 | 
					    <FormField v-slot="{ field }" name="first_name">
 | 
				
			||||||
      <FormItem v-auto-animate>
 | 
					      <FormItem v-auto-animate>
 | 
				
			||||||
        <FormLabel>{{ $t('form.field.firstName') }}</FormLabel>
 | 
					        <FormLabel>{{ $t('globals.terms.firstName') }}</FormLabel>
 | 
				
			||||||
        <FormControl>
 | 
					        <FormControl>
 | 
				
			||||||
          <Input type="text" placeholder="" v-bind="field" />
 | 
					          <Input type="text" placeholder="" v-bind="field" />
 | 
				
			||||||
        </FormControl>
 | 
					        </FormControl>
 | 
				
			||||||
@@ -65,7 +183,7 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    <FormField v-slot="{ field }" name="last_name">
 | 
					    <FormField v-slot="{ field }" name="last_name">
 | 
				
			||||||
      <FormItem>
 | 
					      <FormItem>
 | 
				
			||||||
        <FormLabel>{{ $t('form.field.lastName') }}</FormLabel>
 | 
					        <FormLabel>{{ $t('globals.terms.lastName') }}</FormLabel>
 | 
				
			||||||
        <FormControl>
 | 
					        <FormControl>
 | 
				
			||||||
          <Input type="text" placeholder="" v-bind="field" />
 | 
					          <Input type="text" placeholder="" v-bind="field" />
 | 
				
			||||||
        </FormControl>
 | 
					        </FormControl>
 | 
				
			||||||
@@ -75,7 +193,7 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    <FormField v-slot="{ field }" name="email">
 | 
					    <FormField v-slot="{ field }" name="email">
 | 
				
			||||||
      <FormItem v-auto-animate>
 | 
					      <FormItem v-auto-animate>
 | 
				
			||||||
        <FormLabel>{{ $t('form.field.email') }}</FormLabel>
 | 
					        <FormLabel>{{ $t('globals.terms.email') }}</FormLabel>
 | 
				
			||||||
        <FormControl>
 | 
					        <FormControl>
 | 
				
			||||||
          <Input type="email" placeholder="" v-bind="field" />
 | 
					          <Input type="email" placeholder="" v-bind="field" />
 | 
				
			||||||
        </FormControl>
 | 
					        </FormControl>
 | 
				
			||||||
@@ -85,11 +203,11 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    <FormField v-slot="{ componentField, handleChange }" name="teams">
 | 
					    <FormField v-slot="{ componentField, handleChange }" name="teams">
 | 
				
			||||||
      <FormItem v-auto-animate>
 | 
					      <FormItem v-auto-animate>
 | 
				
			||||||
        <FormLabel>{{ $t('form.field.teams') }}</FormLabel>
 | 
					        <FormLabel>{{ $t('globals.terms.team', 2) }}</FormLabel>
 | 
				
			||||||
        <FormControl>
 | 
					        <FormControl>
 | 
				
			||||||
          <SelectTag
 | 
					          <SelectTag
 | 
				
			||||||
            :items="teamOptions"
 | 
					            :items="teamOptions"
 | 
				
			||||||
            :placeholder="t('form.field.selectTeams')"
 | 
					            :placeholder="t('globals.messages.select', { name: t('globals.terms.team', 2) })"
 | 
				
			||||||
            v-model="componentField.modelValue"
 | 
					            v-model="componentField.modelValue"
 | 
				
			||||||
            @update:modelValue="handleChange"
 | 
					            @update:modelValue="handleChange"
 | 
				
			||||||
          />
 | 
					          />
 | 
				
			||||||
@@ -100,11 +218,15 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    <FormField v-slot="{ componentField, handleChange }" name="roles">
 | 
					    <FormField v-slot="{ componentField, handleChange }" name="roles">
 | 
				
			||||||
      <FormItem v-auto-animate>
 | 
					      <FormItem v-auto-animate>
 | 
				
			||||||
        <FormLabel>{{ $t('form.field.roles') }}</FormLabel>
 | 
					        <FormLabel>{{ $t('globals.terms.role', 2) }}</FormLabel>
 | 
				
			||||||
        <FormControl>
 | 
					        <FormControl>
 | 
				
			||||||
          <SelectTag
 | 
					          <SelectTag
 | 
				
			||||||
            :items="roleOptions"
 | 
					            :items="roleOptions"
 | 
				
			||||||
            :placeholder="t('form.field.selectRoles')"
 | 
					            :placeholder="
 | 
				
			||||||
 | 
					              t('globals.messages.select', {
 | 
				
			||||||
 | 
					                name: $t('globals.terms.role', 2)
 | 
				
			||||||
 | 
					              })
 | 
				
			||||||
 | 
					            "
 | 
				
			||||||
            v-model="componentField.modelValue"
 | 
					            v-model="componentField.modelValue"
 | 
				
			||||||
            @update:modelValue="handleChange"
 | 
					            @update:modelValue="handleChange"
 | 
				
			||||||
          />
 | 
					          />
 | 
				
			||||||
@@ -115,14 +237,14 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    <FormField v-slot="{ componentField }" name="availability_status" v-if="!isNewForm">
 | 
					    <FormField v-slot="{ componentField }" name="availability_status" v-if="!isNewForm">
 | 
				
			||||||
      <FormItem>
 | 
					      <FormItem>
 | 
				
			||||||
        <FormLabel>{{ t('form.field.availabilityStatus') }}</FormLabel>
 | 
					        <FormLabel>{{ t('globals.terms.availabilityStatus') }}</FormLabel>
 | 
				
			||||||
        <FormControl>
 | 
					        <FormControl>
 | 
				
			||||||
          <Select v-bind="componentField" v-model="componentField.modelValue">
 | 
					          <Select v-bind="componentField" v-model="componentField.modelValue">
 | 
				
			||||||
            <SelectTrigger>
 | 
					            <SelectTrigger>
 | 
				
			||||||
              <SelectValue
 | 
					              <SelectValue
 | 
				
			||||||
                :placeholder="
 | 
					                :placeholder="
 | 
				
			||||||
                  t('form.field.select', {
 | 
					                  t('globals.messages.select', {
 | 
				
			||||||
                    name: t('form.field.availabilityStatus')
 | 
					                    name: t('globals.terms.availabilityStatus')
 | 
				
			||||||
                  })
 | 
					                  })
 | 
				
			||||||
                "
 | 
					                "
 | 
				
			||||||
              />
 | 
					              />
 | 
				
			||||||
@@ -132,7 +254,7 @@
 | 
				
			|||||||
                <SelectItem value="active_group">{{ t('globals.terms.active') }}</SelectItem>
 | 
					                <SelectItem value="active_group">{{ t('globals.terms.active') }}</SelectItem>
 | 
				
			||||||
                <SelectItem value="away_manual">{{ t('globals.terms.away') }}</SelectItem>
 | 
					                <SelectItem value="away_manual">{{ t('globals.terms.away') }}</SelectItem>
 | 
				
			||||||
                <SelectItem value="away_and_reassigning">
 | 
					                <SelectItem value="away_and_reassigning">
 | 
				
			||||||
                  {{ t('form.field.awayReassigning') }}
 | 
					                  {{ t('globals.terms.awayReassigning') }}
 | 
				
			||||||
                </SelectItem>
 | 
					                </SelectItem>
 | 
				
			||||||
              </SelectGroup>
 | 
					              </SelectGroup>
 | 
				
			||||||
            </SelectContent>
 | 
					            </SelectContent>
 | 
				
			||||||
@@ -144,7 +266,7 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    <FormField v-slot="{ field }" name="new_password" v-if="!isNewForm">
 | 
					    <FormField v-slot="{ field }" name="new_password" v-if="!isNewForm">
 | 
				
			||||||
      <FormItem v-auto-animate>
 | 
					      <FormItem v-auto-animate>
 | 
				
			||||||
        <FormLabel>{{ t('form.field.setPassword') }}</FormLabel>
 | 
					        <FormLabel>{{ t('globals.terms.setPassword') }}</FormLabel>
 | 
				
			||||||
        <FormControl>
 | 
					        <FormControl>
 | 
				
			||||||
          <Input type="password" placeholder="" v-bind="field" />
 | 
					          <Input type="password" placeholder="" v-bind="field" />
 | 
				
			||||||
        </FormControl>
 | 
					        </FormControl>
 | 
				
			||||||
@@ -157,7 +279,7 @@
 | 
				
			|||||||
        <FormControl>
 | 
					        <FormControl>
 | 
				
			||||||
          <div class="flex items-center space-x-2">
 | 
					          <div class="flex items-center space-x-2">
 | 
				
			||||||
            <Checkbox :checked="value" @update:checked="handleChange" />
 | 
					            <Checkbox :checked="value" @update:checked="handleChange" />
 | 
				
			||||||
            <Label>{{ $t('form.field.sendWelcomeEmail') }}</Label>
 | 
					            <Label>{{ $t('globals.terms.sendWelcomeEmail') }}</Label>
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
        </FormControl>
 | 
					        </FormControl>
 | 
				
			||||||
        <FormMessage />
 | 
					        <FormMessage />
 | 
				
			||||||
@@ -170,7 +292,7 @@
 | 
				
			|||||||
          <Checkbox :checked="value" @update:checked="handleChange" />
 | 
					          <Checkbox :checked="value" @update:checked="handleChange" />
 | 
				
			||||||
        </FormControl>
 | 
					        </FormControl>
 | 
				
			||||||
        <div class="space-y-1 leading-none">
 | 
					        <div class="space-y-1 leading-none">
 | 
				
			||||||
          <FormLabel> {{ $t('form.field.enabled') }} </FormLabel>
 | 
					          <FormLabel> {{ $t('globals.terms.enabled') }} </FormLabel>
 | 
				
			||||||
          <FormMessage />
 | 
					          <FormMessage />
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
      </FormItem>
 | 
					      </FormItem>
 | 
				
			||||||
@@ -190,7 +312,7 @@ import { Checkbox } from '@/components/ui/checkbox'
 | 
				
			|||||||
import { Label } from '@/components/ui/label'
 | 
					import { Label } from '@/components/ui/label'
 | 
				
			||||||
import { vAutoAnimate } from '@formkit/auto-animate/vue'
 | 
					import { vAutoAnimate } from '@formkit/auto-animate/vue'
 | 
				
			||||||
import { Badge } from '@/components/ui/badge'
 | 
					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 { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
 | 
				
			||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
 | 
					import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
@@ -203,7 +325,18 @@ import {
 | 
				
			|||||||
} from '@/components/ui/select'
 | 
					} from '@/components/ui/select'
 | 
				
			||||||
import { SelectTag } from '@/components/ui/select'
 | 
					import { SelectTag } from '@/components/ui/select'
 | 
				
			||||||
import { Input } from '@/components/ui/input'
 | 
					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 { useI18n } from 'vue-i18n'
 | 
				
			||||||
 | 
					import { useEmitter } from '@/composables/useEmitter'
 | 
				
			||||||
 | 
					import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
 | 
				
			||||||
import { format } from 'date-fns'
 | 
					import { format } from 'date-fns'
 | 
				
			||||||
import api from '@/api'
 | 
					import api from '@/api'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -234,6 +367,19 @@ const props = defineProps({
 | 
				
			|||||||
const { t } = useI18n()
 | 
					const { t } = useI18n()
 | 
				
			||||||
const teams = ref([])
 | 
					const teams = ref([])
 | 
				
			||||||
const roles = 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 () => {
 | 
					onMounted(async () => {
 | 
				
			||||||
  try {
 | 
					  try {
 | 
				
			||||||
@@ -241,7 +387,10 @@ onMounted(async () => {
 | 
				
			|||||||
    teams.value = teamsResp.value.data.data
 | 
					    teams.value = teamsResp.value.data.data
 | 
				
			||||||
    roles.value = rolesResp.value.data.data
 | 
					    roles.value = rolesResp.value.data.data
 | 
				
			||||||
  } catch (err) {
 | 
					  } 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 === '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_manual') return { text: t('globals.terms.away'), color: 'bg-yellow-500' }
 | 
				
			||||||
  if (status === 'away_and_reassigning')
 | 
					  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' }
 | 
					  return { text: t('globals.terms.offline'), color: 'bg-gray-400' }
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -269,7 +418,6 @@ const onSubmit = form.handleSubmit((values) => {
 | 
				
			|||||||
  if (values.availability_status === 'active_group') {
 | 
					  if (values.availability_status === 'active_group') {
 | 
				
			||||||
    values.availability_status = 'online'
 | 
					    values.availability_status = 'online'
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  values.teams = values.teams.map((team) => ({ name: team }))
 | 
					 | 
				
			||||||
  props.submitForm(values)
 | 
					  props.submitForm(values)
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -280,6 +428,87 @@ const getInitials = (firstName, lastName) => {
 | 
				
			|||||||
  return `${firstName.charAt(0).toUpperCase()}${lastName.charAt(0).toUpperCase()}`
 | 
					  return `${firstName.charAt(0).toUpperCase()}${lastName.charAt(0).toUpperCase()}`
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const generateAPIKey = async () => {
 | 
				
			||||||
 | 
					  if (!props.initialValues?.id) return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    isAPIKeyLoading.value = true
 | 
				
			||||||
 | 
					    const response = await api.generateAPIKey(props.initialValues.id)
 | 
				
			||||||
 | 
					    if (response.data) {
 | 
				
			||||||
 | 
					      const responseData = response.data.data
 | 
				
			||||||
 | 
					      newAPIKeyData.value = {
 | 
				
			||||||
 | 
					        api_key: responseData.api_key,
 | 
				
			||||||
 | 
					        api_secret: responseData.api_secret
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      apiKeyData.value.api_key = responseData.api_key
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Clear the last used timestamp since this is a new API key
 | 
				
			||||||
 | 
					      apiKeyLastUsedAt.value = null
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      showAPIKeyDialog.value = true
 | 
				
			||||||
 | 
					      emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
				
			||||||
 | 
					        description: t('globals.messages.generatedSuccessfully', {
 | 
				
			||||||
 | 
					          name: t('globals.terms.apiKey')
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
				
			||||||
 | 
					      variant: 'destructive',
 | 
				
			||||||
 | 
					      description: t('globals.messages.errorGenerating', {
 | 
				
			||||||
 | 
					        name: t('globals.terms.apiKey')
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					  } finally {
 | 
				
			||||||
 | 
					    isAPIKeyLoading.value = false
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const regenerateAPIKey = async () => {
 | 
				
			||||||
 | 
					  await generateAPIKey()
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const revokeAPIKey = async () => {
 | 
				
			||||||
 | 
					  if (!props.initialValues?.id) return
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    isAPIKeyLoading.value = true
 | 
				
			||||||
 | 
					    await api.revokeAPIKey(props.initialValues.id)
 | 
				
			||||||
 | 
					    apiKeyData.value.api_key = ''
 | 
				
			||||||
 | 
					    apiKeyData.value.api_secret = ''
 | 
				
			||||||
 | 
					    apiKeyLastUsedAt.value = null
 | 
				
			||||||
 | 
					    emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
				
			||||||
 | 
					      description: t('globals.messages.revokedSuccessfully', {
 | 
				
			||||||
 | 
					        name: t('globals.terms.apiKey')
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
				
			||||||
 | 
					      variant: 'destructive',
 | 
				
			||||||
 | 
					      description: t('globals.messages.errorRevoking', {
 | 
				
			||||||
 | 
					        name: t('globals.terms.apiKey')
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					  } finally {
 | 
				
			||||||
 | 
					    isAPIKeyLoading.value = false
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const copyToClipboard = async (text) => {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    await navigator.clipboard.writeText(text)
 | 
				
			||||||
 | 
					    emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
				
			||||||
 | 
					      description: t('globals.messages.copied')
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error('Error copying to clipboard:', error)
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const closeAPIKeyModal = () => {
 | 
				
			||||||
 | 
					  showAPIKeyDialog.value = false
 | 
				
			||||||
 | 
					  newAPIKeyData.value = { api_key: '', api_secret: '' }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
watch(
 | 
					watch(
 | 
				
			||||||
  () => props.initialValues,
 | 
					  () => props.initialValues,
 | 
				
			||||||
  (newValues) => {
 | 
					  (newValues) => {
 | 
				
			||||||
@@ -298,6 +527,10 @@ watch(
 | 
				
			|||||||
          'teams',
 | 
					          'teams',
 | 
				
			||||||
          newValues.teams.map((team) => team.name)
 | 
					          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)
 | 
					      }, 0)
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -6,48 +6,48 @@ export const createColumns = (t) => [
 | 
				
			|||||||
  {
 | 
					  {
 | 
				
			||||||
    accessorKey: 'first_name',
 | 
					    accessorKey: 'first_name',
 | 
				
			||||||
    header: function () {
 | 
					    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 }) {
 | 
					    cell: function ({ row }) {
 | 
				
			||||||
      return h('div', { class: 'text-center font-medium' }, row.getValue('first_name'))
 | 
					      return h('div', { class: 'text-center' }, row.getValue('first_name'))
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  {
 | 
					  {
 | 
				
			||||||
    accessorKey: 'last_name',
 | 
					    accessorKey: 'last_name',
 | 
				
			||||||
    header: function () {
 | 
					    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 }) {
 | 
					    cell: function ({ row }) {
 | 
				
			||||||
      return h('div', { class: 'text-center font-medium' }, row.getValue('last_name'))
 | 
					      return h('div', { class: 'text-center' }, row.getValue('last_name'))
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  {
 | 
					  {
 | 
				
			||||||
    accessorKey: 'enabled',
 | 
					    accessorKey: 'enabled',
 | 
				
			||||||
    header: function () {
 | 
					    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 }) {
 | 
					    cell: function ({ row }) {
 | 
				
			||||||
      return h('div', { class: 'text-center font-medium' }, row.getValue('enabled') ? t('globals.messages.yes') : t('globals.messages.no'))
 | 
					      return h('div', { class: 'text-center' }, row.getValue('enabled') ? t('globals.messages.yes') : t('globals.messages.no'))
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  {
 | 
					  {
 | 
				
			||||||
    accessorKey: 'email',
 | 
					    accessorKey: 'email',
 | 
				
			||||||
    header: function () {
 | 
					    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 }) {
 | 
					    cell: function ({ row }) {
 | 
				
			||||||
      return h('div', { class: 'text-center font-medium' }, row.getValue('email'))
 | 
					      return h('div', { class: 'text-center' }, row.getValue('email'))
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  {
 | 
					  {
 | 
				
			||||||
    accessorKey: 'created_at',
 | 
					    accessorKey: 'created_at',
 | 
				
			||||||
    header: function () {
 | 
					    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 }) {
 | 
					    cell: function ({ row }) {
 | 
				
			||||||
      return h(
 | 
					      return h(
 | 
				
			||||||
        'div',
 | 
					        'div',
 | 
				
			||||||
        { class: 'text-center font-medium' },
 | 
					        { class: 'text-center' },
 | 
				
			||||||
        format(row.getValue('created_at'), 'PPpp')
 | 
					        format(row.getValue('created_at'), 'PPpp')
 | 
				
			||||||
      )
 | 
					      )
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@@ -55,12 +55,12 @@ export const createColumns = (t) => [
 | 
				
			|||||||
  {
 | 
					  {
 | 
				
			||||||
    accessorKey: 'updated_at',
 | 
					    accessorKey: 'updated_at',
 | 
				
			||||||
    header: function () {
 | 
					    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 }) {
 | 
					    cell: function ({ row }) {
 | 
				
			||||||
      return h(
 | 
					      return h(
 | 
				
			||||||
        'div',
 | 
					        'div',
 | 
				
			||||||
        { class: 'text-center font-medium' },
 | 
					        { class: 'text-center' },
 | 
				
			||||||
        format(row.getValue('updated_at'), 'PPpp')
 | 
					        format(row.getValue('updated_at'), 'PPpp')
 | 
				
			||||||
      )
 | 
					      )
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -8,10 +8,10 @@
 | 
				
			|||||||
    </DropdownMenuTrigger>
 | 
					    </DropdownMenuTrigger>
 | 
				
			||||||
    <DropdownMenuContent>
 | 
					    <DropdownMenuContent>
 | 
				
			||||||
      <DropdownMenuItem @click="editUser(props.user.id)">{{
 | 
					      <DropdownMenuItem @click="editUser(props.user.id)">{{
 | 
				
			||||||
        $t('globals.buttons.edit')
 | 
					        $t('globals.messages.edit')
 | 
				
			||||||
      }}</DropdownMenuItem>
 | 
					      }}</DropdownMenuItem>
 | 
				
			||||||
      <DropdownMenuItem @click="() => (alertOpen = true)">{{
 | 
					      <DropdownMenuItem @click="() => (alertOpen = true)">{{
 | 
				
			||||||
        $t('globals.buttons.delete')
 | 
					        $t('globals.messages.delete')
 | 
				
			||||||
      }}</DropdownMenuItem>
 | 
					      }}</DropdownMenuItem>
 | 
				
			||||||
    </DropdownMenuContent>
 | 
					    </DropdownMenuContent>
 | 
				
			||||||
  </DropdownMenu>
 | 
					  </DropdownMenu>
 | 
				
			||||||
@@ -20,12 +20,12 @@
 | 
				
			|||||||
    <AlertDialogContent>
 | 
					    <AlertDialogContent>
 | 
				
			||||||
      <AlertDialogHeader>
 | 
					      <AlertDialogHeader>
 | 
				
			||||||
        <AlertDialogTitle>{{ $t('globals.messages.areYouAbsolutelySure') }}</AlertDialogTitle>
 | 
					        <AlertDialogTitle>{{ $t('globals.messages.areYouAbsolutelySure') }}</AlertDialogTitle>
 | 
				
			||||||
        <AlertDialogDescription>{{ $t('admin.user.deleteConfirmation') }}</AlertDialogDescription>
 | 
					        <AlertDialogDescription>{{ $t('admin.agent.deleteConfirmation') }}</AlertDialogDescription>
 | 
				
			||||||
      </AlertDialogHeader>
 | 
					      </AlertDialogHeader>
 | 
				
			||||||
      <AlertDialogFooter>
 | 
					      <AlertDialogFooter>
 | 
				
			||||||
        <AlertDialogCancel>{{ $t('globals.buttons.cancel') }}</AlertDialogCancel>
 | 
					        <AlertDialogCancel>{{ $t('globals.messages.cancel') }}</AlertDialogCancel>
 | 
				
			||||||
        <AlertDialogAction @click="handleDelete">{{
 | 
					        <AlertDialogAction @click="handleDelete">{{
 | 
				
			||||||
          $t('globals.buttons.delete')
 | 
					          $t('globals.messages.delete')
 | 
				
			||||||
        }}</AlertDialogAction>
 | 
					        }}</AlertDialogAction>
 | 
				
			||||||
      </AlertDialogFooter>
 | 
					      </AlertDialogFooter>
 | 
				
			||||||
    </AlertDialogContent>
 | 
					    </AlertDialogContent>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -32,18 +32,22 @@ export const createFormSchema = (t) => z.object({
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  teams: z.array(z.string()).default([]),
 | 
					  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')
 | 
					    name: t('globals.terms.role')
 | 
				
			||||||
  })),
 | 
					  })),
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  new_password: z
 | 
					  new_password: z
 | 
				
			||||||
    .string()
 | 
					    .string()
 | 
				
			||||||
    .regex(/^(?=.*[A-Z])(?=.*[a-z])(?=.*\d)(?=.*[\W_]).{10,72}$/, {
 | 
					    .min(10, {
 | 
				
			||||||
      message: t('globals.messages.strongPassword', {
 | 
					      message: t('globals.messages.strongPassword', { min: 10, max: 72 })
 | 
				
			||||||
        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(),
 | 
					    .optional(),
 | 
				
			||||||
  enabled: z.boolean().optional().default(true),
 | 
					  enabled: z.boolean().optional().default(true),
 | 
				
			||||||
  availability_status: z.string().optional().default('offline'),
 | 
					  availability_status: z.string().optional().default('offline'),
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										378
									
								
								frontend/src/features/admin/agents/schema.test.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										378
									
								
								frontend/src/features/admin/agents/schema.test.js
									
									
									
									
									
										Normal 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()
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
@@ -16,7 +16,7 @@
 | 
				
			|||||||
                  @update:modelValue="(value) => handleFieldChange(value, index)"
 | 
					                  @update:modelValue="(value) => handleFieldChange(value, index)"
 | 
				
			||||||
                >
 | 
					                >
 | 
				
			||||||
                  <SelectTrigger class="m-auto">
 | 
					                  <SelectTrigger class="m-auto">
 | 
				
			||||||
                    <SelectValue :placeholder="t('form.field.selectAction')" />
 | 
					                    <SelectValue :placeholder="t('globals.messages.select', { name: t('globals.terms.action').toLowerCase() })" />
 | 
				
			||||||
                  </SelectTrigger>
 | 
					                  </SelectTrigger>
 | 
				
			||||||
                  <SelectContent>
 | 
					                  <SelectContent>
 | 
				
			||||||
                    <SelectGroup>
 | 
					                    <SelectGroup>
 | 
				
			||||||
@@ -40,7 +40,7 @@
 | 
				
			|||||||
                <SelectTag
 | 
					                <SelectTag
 | 
				
			||||||
                  v-model="action.value"
 | 
					                  v-model="action.value"
 | 
				
			||||||
                  :items="tagsStore.tagNames.map((tag) => ({ label: tag, value: tag }))"
 | 
					                  :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>
 | 
					              </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -48,63 +48,17 @@
 | 
				
			|||||||
                class="w-48"
 | 
					                class="w-48"
 | 
				
			||||||
                v-if="action.type && conversationActions[action.type]?.type === 'select'"
 | 
					                v-if="action.type && conversationActions[action.type]?.type === 'select'"
 | 
				
			||||||
              >
 | 
					              >
 | 
				
			||||||
                <ComboBox
 | 
					                <SelectComboBox
 | 
				
			||||||
                  v-model="action.value[0]"
 | 
					                  v-model="action.value[0]"
 | 
				
			||||||
                  :items="conversationActions[action.type]?.options"
 | 
					                  :items="conversationActions[action.type]?.options"
 | 
				
			||||||
                  :placeholder="t('form.field.select')"
 | 
					                  :placeholder="t('globals.messages.select', { name: '' })"
 | 
				
			||||||
                  @select="handleValueChange($event, index)"
 | 
					                  @select="handleValueChange($event, index)"
 | 
				
			||||||
                >
 | 
					                  :type="action.type === 'assign_team' ? 'team' : 'user'"
 | 
				
			||||||
                  <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>
 | 
					 | 
				
			||||||
              </div>
 | 
					              </div>
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            <div class="cursor-pointer" @click.prevent="removeAction(index)">
 | 
					            <CloseButton :onClose="() => removeAction(index)" />
 | 
				
			||||||
              <X size="16" />
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          <div
 | 
					          <div
 | 
				
			||||||
@@ -112,9 +66,10 @@
 | 
				
			|||||||
            v-if="action.type && conversationActions[action.type]?.type === 'richtext'"
 | 
					            v-if="action.type && conversationActions[action.type]?.type === 'richtext'"
 | 
				
			||||||
          >
 | 
					          >
 | 
				
			||||||
            <Editor
 | 
					            <Editor
 | 
				
			||||||
 | 
					              :autoFocus="false"
 | 
				
			||||||
              v-model:htmlContent="action.value[0]"
 | 
					              v-model:htmlContent="action.value[0]"
 | 
				
			||||||
              @update:htmlContent="(value) => handleEditorChange(value, index)"
 | 
					              @update:htmlContent="(value) => handleEditorChange(value, index)"
 | 
				
			||||||
              :placeholder="t('editor.placeholder')"
 | 
					              :placeholder="t('editor.newLine') + t('editor.send') + t('editor.ctrlK')"
 | 
				
			||||||
            />
 | 
					            />
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
@@ -133,7 +88,7 @@
 | 
				
			|||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { toRefs } from 'vue'
 | 
					import { toRefs } from 'vue'
 | 
				
			||||||
import { Button } from '@/components/ui/button'
 | 
					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 { useTagStore } from '@/stores/tag'
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  Select,
 | 
					  Select,
 | 
				
			||||||
@@ -143,13 +98,12 @@ import {
 | 
				
			|||||||
  SelectTrigger,
 | 
					  SelectTrigger,
 | 
				
			||||||
  SelectValue
 | 
					  SelectValue
 | 
				
			||||||
} from '@/components/ui/select'
 | 
					} 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 { SelectTag } from '@/components/ui/select'
 | 
				
			||||||
import { useConversationFilters } from '@/composables/useConversationFilters'
 | 
					import { useConversationFilters } from '@/composables/useConversationFilters'
 | 
				
			||||||
import { getTextFromHTML } from '@/utils/strings.js'
 | 
					import { getTextFromHTML } from '@/utils/strings.js'
 | 
				
			||||||
import { useI18n } from 'vue-i18n'
 | 
					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({
 | 
					const props = defineProps({
 | 
				
			||||||
  actions: {
 | 
					  actions: {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -37,7 +37,7 @@
 | 
				
			|||||||
              @update:modelValue="(value) => handleFieldChange(value, index)"
 | 
					              @update:modelValue="(value) => handleFieldChange(value, index)"
 | 
				
			||||||
            >
 | 
					            >
 | 
				
			||||||
              <SelectTrigger class="w-56">
 | 
					              <SelectTrigger class="w-56">
 | 
				
			||||||
                <SelectValue :placeholder="t('form.field.selectField')" />
 | 
					                <SelectValue :placeholder="t('globals.messages.select', { name: t('globals.terms.field').toLowerCase() })" />
 | 
				
			||||||
              </SelectTrigger>
 | 
					              </SelectTrigger>
 | 
				
			||||||
              <SelectContent>
 | 
					              <SelectContent>
 | 
				
			||||||
                <SelectGroup>
 | 
					                <SelectGroup>
 | 
				
			||||||
@@ -65,7 +65,7 @@
 | 
				
			|||||||
              @update:modelValue="(value) => handleOperatorChange(value, index)"
 | 
					              @update:modelValue="(value) => handleOperatorChange(value, index)"
 | 
				
			||||||
            >
 | 
					            >
 | 
				
			||||||
              <SelectTrigger class="w-56">
 | 
					              <SelectTrigger class="w-56">
 | 
				
			||||||
                <SelectValue :placeholder="t('form.field.selectOperator')" />
 | 
					                <SelectValue :placeholder="t('globals.messages.select', { name: t('globals.terms.operator').toLowerCase() })" />
 | 
				
			||||||
              </SelectTrigger>
 | 
					              </SelectTrigger>
 | 
				
			||||||
              <SelectContent>
 | 
					              <SelectContent>
 | 
				
			||||||
                <SelectGroup>
 | 
					                <SelectGroup>
 | 
				
			||||||
@@ -85,7 +85,7 @@
 | 
				
			|||||||
              <!-- Plain text input -->
 | 
					              <!-- Plain text input -->
 | 
				
			||||||
              <Input
 | 
					              <Input
 | 
				
			||||||
                type="text"
 | 
					                type="text"
 | 
				
			||||||
                :placeholder="t('form.field.setValue')"
 | 
					                :placeholder="t('globals.messages.set', { name: t('globals.terms.value').toLowerCase() })"
 | 
				
			||||||
                v-if="inputType(index) === 'text'"
 | 
					                v-if="inputType(index) === 'text'"
 | 
				
			||||||
                v-model="rule.value"
 | 
					                v-model="rule.value"
 | 
				
			||||||
                @update:modelValue="(value) => handleValueChange(value, index)"
 | 
					                @update:modelValue="(value) => handleValueChange(value, index)"
 | 
				
			||||||
@@ -94,7 +94,7 @@
 | 
				
			|||||||
              <!-- Number input -->
 | 
					              <!-- Number input -->
 | 
				
			||||||
              <Input
 | 
					              <Input
 | 
				
			||||||
                type="number"
 | 
					                type="number"
 | 
				
			||||||
                :placeholder="t('form.field.setValue')"
 | 
					                :placeholder="t('globals.messages.set', { name: t('globals.terms.value').toLowerCase() })"
 | 
				
			||||||
                v-if="inputType(index) === 'number'"
 | 
					                v-if="inputType(index) === 'number'"
 | 
				
			||||||
                v-model="rule.value"
 | 
					                v-model="rule.value"
 | 
				
			||||||
                @update:modelValue="(value) => handleValueChange(value, index)"
 | 
					                @update:modelValue="(value) => handleValueChange(value, index)"
 | 
				
			||||||
@@ -102,59 +102,12 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
              <!-- Select input -->
 | 
					              <!-- Select input -->
 | 
				
			||||||
              <div v-if="inputType(index) === 'select'">
 | 
					              <div v-if="inputType(index) === 'select'">
 | 
				
			||||||
                <ComboBox
 | 
					                <SelectComboBox
 | 
				
			||||||
                  v-model="rule.value"
 | 
					                  v-model="rule.value"
 | 
				
			||||||
                  :items="getFieldOptions(rule.field, rule.field_type)"
 | 
					                  :items="getFieldOptions(rule.field, rule.field_type)"
 | 
				
			||||||
                  @select="handleValueChange($event, index)"
 | 
					                  @select="handleValueChange($event, index)"
 | 
				
			||||||
                >
 | 
					                  :type="rule.field === 'assigned_user' ? 'user' : 'team'"
 | 
				
			||||||
                  <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>
 | 
					 | 
				
			||||||
              </div>
 | 
					              </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
              <!-- Tag input -->
 | 
					              <!-- Tag input -->
 | 
				
			||||||
@@ -171,7 +124,7 @@
 | 
				
			|||||||
                    <TagsInputItemText />
 | 
					                    <TagsInputItemText />
 | 
				
			||||||
                    <TagsInputItemDelete />
 | 
					                    <TagsInputItemDelete />
 | 
				
			||||||
                  </TagsInputItem>
 | 
					                  </TagsInputItem>
 | 
				
			||||||
                  <TagsInputInput :placeholder="t('form.field.selectValue')" />
 | 
					                  <TagsInputInput :placeholder="t('globals.messages.select', { name: t('globals.terms.value').toLowerCase() })" />
 | 
				
			||||||
                </TagsInput>
 | 
					                </TagsInput>
 | 
				
			||||||
                <p class="text-xs text-gray-500 mt-1">
 | 
					                <p class="text-xs text-gray-500 mt-1">
 | 
				
			||||||
                  {{ $t('globals.messages.pressEnterToSelectAValue') }}
 | 
					                  {{ $t('globals.messages.pressEnterToSelectAValue') }}
 | 
				
			||||||
@@ -181,7 +134,7 @@
 | 
				
			|||||||
              <!-- Date input -->
 | 
					              <!-- Date input -->
 | 
				
			||||||
              <Input
 | 
					              <Input
 | 
				
			||||||
                type="date"
 | 
					                type="date"
 | 
				
			||||||
                :placeholder="t('form.field.setValue')"
 | 
					                :placeholder="t('globals.messages.set', { name: t('globals.terms.value').toLowerCase() })"
 | 
				
			||||||
                v-if="inputType(index) === 'date'"
 | 
					                v-if="inputType(index) === 'date'"
 | 
				
			||||||
                v-model="rule.value"
 | 
					                v-model="rule.value"
 | 
				
			||||||
                @update:modelValue="(value) => handleValueChange(value, index)"
 | 
					                @update:modelValue="(value) => handleValueChange(value, index)"
 | 
				
			||||||
@@ -194,7 +147,7 @@
 | 
				
			|||||||
                v-if="inputType(index) === 'boolean'"
 | 
					                v-if="inputType(index) === 'boolean'"
 | 
				
			||||||
              >
 | 
					              >
 | 
				
			||||||
                <SelectTrigger>
 | 
					                <SelectTrigger>
 | 
				
			||||||
                  <SelectValue :placeholder="t('form.field.selectValue')" />
 | 
					                  <SelectValue :placeholder="t('globals.messages.select', { name: t('globals.terms.value').toLowerCase() })" />
 | 
				
			||||||
                </SelectTrigger>
 | 
					                </SelectTrigger>
 | 
				
			||||||
                <SelectContent>
 | 
					                <SelectContent>
 | 
				
			||||||
                  <SelectGroup>
 | 
					                  <SelectGroup>
 | 
				
			||||||
@@ -209,9 +162,7 @@
 | 
				
			|||||||
            <div v-else class="flex-1"></div>
 | 
					            <div v-else class="flex-1"></div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            <!-- Remove condition -->
 | 
					            <!-- Remove condition -->
 | 
				
			||||||
            <div class="cursor-pointer mt-2" @click.prevent="removeCondition(index)">
 | 
					            <CloseButton :onClose="() => removeCondition(index)" />
 | 
				
			||||||
              <X size="16" />
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          <div class="flex items-center space-x-2">
 | 
					          <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 { Checkbox } from '@/components/ui/checkbox'
 | 
				
			||||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
 | 
					import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
 | 
				
			||||||
import { Button } from '@/components/ui/button'
 | 
					import { Button } from '@/components/ui/button'
 | 
				
			||||||
 | 
					import CloseButton from '@/components/button/CloseButton.vue'
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  Select,
 | 
					  Select,
 | 
				
			||||||
  SelectContent,
 | 
					  SelectContent,
 | 
				
			||||||
@@ -258,13 +210,11 @@ import {
 | 
				
			|||||||
  TagsInputItemDelete,
 | 
					  TagsInputItemDelete,
 | 
				
			||||||
  TagsInputItemText
 | 
					  TagsInputItemText
 | 
				
			||||||
} from '@/components/ui/tags-input'
 | 
					} from '@/components/ui/tags-input'
 | 
				
			||||||
import { X } from 'lucide-vue-next'
 | 
					 | 
				
			||||||
import { Label } from '@/components/ui/label'
 | 
					import { Label } from '@/components/ui/label'
 | 
				
			||||||
import { Input } from '@/components/ui/input'
 | 
					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 { useI18n } from 'vue-i18n'
 | 
				
			||||||
import { useConversationFilters } from '@/composables/useConversationFilters'
 | 
					import { useConversationFilters } from '@/composables/useConversationFilters'
 | 
				
			||||||
 | 
					import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = defineProps({
 | 
					const props = defineProps({
 | 
				
			||||||
  ruleGroup: {
 | 
					  ruleGroup: {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -7,8 +7,8 @@
 | 
				
			|||||||
            {{ rule.name }}
 | 
					            {{ rule.name }}
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
          <div class="mb-1">
 | 
					          <div class="mb-1">
 | 
				
			||||||
            <Badge v-if="rule.enabled" class="text-[9px]">{{ $t('form.field.enabled') }}</Badge>
 | 
					            <Badge v-if="rule.enabled" class="text-[9px]">{{ $t('globals.terms.enabled') }}</Badge>
 | 
				
			||||||
            <Badge v-else variant="secondary">{{ $t('form.field.disabled') }}</Badge>
 | 
					            <Badge v-else variant="secondary">{{ $t('globals.terms.disabled') }}</Badge>
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
        </span>
 | 
					        </span>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
@@ -21,16 +21,16 @@
 | 
				
			|||||||
          </DropdownMenuTrigger>
 | 
					          </DropdownMenuTrigger>
 | 
				
			||||||
          <DropdownMenuContent>
 | 
					          <DropdownMenuContent>
 | 
				
			||||||
            <DropdownMenuItem @click="navigateToEditRule(rule.id)">
 | 
					            <DropdownMenuItem @click="navigateToEditRule(rule.id)">
 | 
				
			||||||
              <span>{{ $t('globals.buttons.edit') }}</span>
 | 
					              <span>{{ $t('globals.messages.edit') }}</span>
 | 
				
			||||||
            </DropdownMenuItem>
 | 
					            </DropdownMenuItem>
 | 
				
			||||||
            <DropdownMenuItem @click="() => (alertOpen = true)">
 | 
					            <DropdownMenuItem @click="() => (alertOpen = true)">
 | 
				
			||||||
              <span>{{ $t('globals.buttons.delete') }}</span>
 | 
					              <span>{{ $t('globals.messages.delete') }}</span>
 | 
				
			||||||
            </DropdownMenuItem>
 | 
					            </DropdownMenuItem>
 | 
				
			||||||
            <DropdownMenuItem @click="$emit('toggle-rule', rule.id)" v-if="rule.enabled">
 | 
					            <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>
 | 
				
			||||||
            <DropdownMenuItem @click="$emit('toggle-rule', rule.id)" v-else>
 | 
					            <DropdownMenuItem @click="$emit('toggle-rule', rule.id)" v-else>
 | 
				
			||||||
              <span>{{ $t('globals.buttons.enable') }}</span>
 | 
					              <span>{{ $t('globals.messages.enable') }}</span>
 | 
				
			||||||
            </DropdownMenuItem>
 | 
					            </DropdownMenuItem>
 | 
				
			||||||
          </DropdownMenuContent>
 | 
					          </DropdownMenuContent>
 | 
				
			||||||
        </DropdownMenu>
 | 
					        </DropdownMenu>
 | 
				
			||||||
@@ -44,13 +44,17 @@
 | 
				
			|||||||
      <AlertDialogHeader>
 | 
					      <AlertDialogHeader>
 | 
				
			||||||
        <AlertDialogTitle>{{ $t('globals.messages.areYouAbsolutelySure') }}</AlertDialogTitle>
 | 
					        <AlertDialogTitle>{{ $t('globals.messages.areYouAbsolutelySure') }}</AlertDialogTitle>
 | 
				
			||||||
        <AlertDialogDescription>
 | 
					        <AlertDialogDescription>
 | 
				
			||||||
          {{ $t('admin.automation.deleteConfirmation') }}
 | 
					          {{
 | 
				
			||||||
 | 
					            $t('globals.messages.deletionConfirmation', {
 | 
				
			||||||
 | 
					              name: $t('globals.terms.automationRule').toLowerCase()
 | 
				
			||||||
 | 
					            })
 | 
				
			||||||
 | 
					          }}
 | 
				
			||||||
        </AlertDialogDescription>
 | 
					        </AlertDialogDescription>
 | 
				
			||||||
      </AlertDialogHeader>
 | 
					      </AlertDialogHeader>
 | 
				
			||||||
      <AlertDialogFooter>
 | 
					      <AlertDialogFooter>
 | 
				
			||||||
        <AlertDialogCancel>{{ $t('globals.buttons.cancel') }}</AlertDialogCancel>
 | 
					        <AlertDialogCancel>{{ $t('globals.messages.cancel') }}</AlertDialogCancel>
 | 
				
			||||||
        <AlertDialogAction @click="handleDelete">{{
 | 
					        <AlertDialogAction @click="handleDelete">{{
 | 
				
			||||||
          $t('globals.buttons.delete')
 | 
					          $t('globals.messages.delete')
 | 
				
			||||||
        }}</AlertDialogAction>
 | 
					        }}</AlertDialogAction>
 | 
				
			||||||
      </AlertDialogFooter>
 | 
					      </AlertDialogFooter>
 | 
				
			||||||
    </AlertDialogContent>
 | 
					    </AlertDialogContent>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -23,6 +23,21 @@
 | 
				
			|||||||
      </Select>
 | 
					      </Select>
 | 
				
			||||||
    </div>
 | 
					    </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 class="space-y-4">
 | 
				
			||||||
      <div v-if="type === 'new_conversation'">
 | 
					      <div v-if="type === 'new_conversation'">
 | 
				
			||||||
        <draggable v-model="rules" class="space-y-5" item-key="id" @end="onDragEnd">
 | 
					        <draggable v-model="rules" class="space-y-5" item-key="id" @end="onDragEnd">
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -18,7 +18,7 @@ export const createFormSchema = (t) => z
 | 
				
			|||||||
        if (data.type === 'conversation_update' && (!data.events || data.events.length === 0)) {
 | 
					        if (data.type === 'conversation_update' && (!data.events || data.events.length === 0)) {
 | 
				
			||||||
            ctx.addIssue({
 | 
					            ctx.addIssue({
 | 
				
			||||||
                path: ['events'],
 | 
					                path: ['events'],
 | 
				
			||||||
                message: t('globals.messages.pleaseSelectAtLeastOne', {
 | 
					                message: t('globals.messages.selectAtLeastOne', {
 | 
				
			||||||
                    name: t('globals.terms.event')
 | 
					                    name: t('globals.terms.event')
 | 
				
			||||||
                }),
 | 
					                }),
 | 
				
			||||||
                code: z.ZodIssueCode.custom,
 | 
					                code: z.ZodIssueCode.custom,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -3,7 +3,7 @@
 | 
				
			|||||||
    <FormField v-slot="{ componentField }" name="name">
 | 
					    <FormField v-slot="{ componentField }" name="name">
 | 
				
			||||||
      <FormItem>
 | 
					      <FormItem>
 | 
				
			||||||
        <FormLabel>
 | 
					        <FormLabel>
 | 
				
			||||||
          {{ t('form.field.name') }}
 | 
					          {{ t('globals.terms.name') }}
 | 
				
			||||||
        </FormLabel>
 | 
					        </FormLabel>
 | 
				
			||||||
        <FormControl>
 | 
					        <FormControl>
 | 
				
			||||||
          <Input type="text" placeholder="" v-bind="componentField" />
 | 
					          <Input type="text" placeholder="" v-bind="componentField" />
 | 
				
			||||||
@@ -15,7 +15,7 @@
 | 
				
			|||||||
    <FormField v-slot="{ componentField }" name="description">
 | 
					    <FormField v-slot="{ componentField }" name="description">
 | 
				
			||||||
      <FormItem>
 | 
					      <FormItem>
 | 
				
			||||||
        <FormLabel>
 | 
					        <FormLabel>
 | 
				
			||||||
          {{ t('form.field.description') }}
 | 
					          {{ t('globals.terms.description') }}
 | 
				
			||||||
        </FormLabel>
 | 
					        </FormLabel>
 | 
				
			||||||
        <FormControl>
 | 
					        <FormControl>
 | 
				
			||||||
          <Input type="text" placeholder="" v-bind="componentField" />
 | 
					          <Input type="text" placeholder="" v-bind="componentField" />
 | 
				
			||||||
@@ -62,7 +62,7 @@
 | 
				
			|||||||
                  :checked="!!selectedDays[day]"
 | 
					                  :checked="!!selectedDays[day]"
 | 
				
			||||||
                  @update:checked="handleDayToggle(day, $event)"
 | 
					                  @update:checked="handleDayToggle(day, $event)"
 | 
				
			||||||
                />
 | 
					                />
 | 
				
			||||||
                <Label :for="day" class="font-medium text-gray-800">{{ day }}</Label>
 | 
					                <Label :for="day" class="font-medium">{{ day }}</Label>
 | 
				
			||||||
              </div>
 | 
					              </div>
 | 
				
			||||||
              <div class="flex space-x-2 items-center">
 | 
					              <div class="flex space-x-2 items-center">
 | 
				
			||||||
                <div class="flex flex-col items-start">
 | 
					                <div class="flex flex-col items-start">
 | 
				
			||||||
@@ -106,7 +106,7 @@
 | 
				
			|||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
      <SimpleTable
 | 
					      <SimpleTable
 | 
				
			||||||
        :headers="[t('form.field.name'), t('form.field.date')]"
 | 
					        :headers="[t('globals.terms.name'), t('globals.terms.date')]"
 | 
				
			||||||
        :keys="['name', 'date']"
 | 
					        :keys="['name', 'date']"
 | 
				
			||||||
        :data="holidays"
 | 
					        :data="holidays"
 | 
				
			||||||
        @deleteItem="deleteHoliday"
 | 
					        @deleteItem="deleteHoliday"
 | 
				
			||||||
@@ -124,11 +124,11 @@
 | 
				
			|||||||
        </DialogHeader>
 | 
					        </DialogHeader>
 | 
				
			||||||
        <div class="grid gap-4 py-4">
 | 
					        <div class="grid gap-4 py-4">
 | 
				
			||||||
          <div class="grid grid-cols-4 items-center gap-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" />
 | 
					            <Input id="holiday_name" v-model="holidayName" class="col-span-3" />
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
          <div class="grid grid-cols-4 items-center gap-4">
 | 
					          <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>
 | 
					            <Popover>
 | 
				
			||||||
              <PopoverTrigger as-child>
 | 
					              <PopoverTrigger as-child>
 | 
				
			||||||
                <Button
 | 
					                <Button
 | 
				
			||||||
@@ -144,7 +144,7 @@
 | 
				
			|||||||
                  {{
 | 
					                  {{
 | 
				
			||||||
                    holidayDate && !isNaN(new Date(holidayDate).getTime())
 | 
					                    holidayDate && !isNaN(new Date(holidayDate).getTime())
 | 
				
			||||||
                      ? format(new Date(holidayDate), 'MMMM dd, yyyy')
 | 
					                      ? format(new Date(holidayDate), 'MMMM dd, yyyy')
 | 
				
			||||||
                      : t('form.field.pickDate')
 | 
					                      : t('globals.terms.pickDate')
 | 
				
			||||||
                  }}
 | 
					                  }}
 | 
				
			||||||
                </Button>
 | 
					                </Button>
 | 
				
			||||||
              </PopoverTrigger>
 | 
					              </PopoverTrigger>
 | 
				
			||||||
@@ -156,7 +156,7 @@
 | 
				
			|||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
        <DialogFooter>
 | 
					        <DialogFooter>
 | 
				
			||||||
          <Button :disabled="!holidayName || !holidayDate" @click="saveHoliday">
 | 
					          <Button :disabled="!holidayName || !holidayDate" @click="saveHoliday">
 | 
				
			||||||
            {{ t('globals.buttons.saveChanges') }}
 | 
					            {{ t('globals.messages.add') }}
 | 
				
			||||||
          </Button>
 | 
					          </Button>
 | 
				
			||||||
        </DialogFooter>
 | 
					        </DialogFooter>
 | 
				
			||||||
      </DialogContent>
 | 
					      </DialogContent>
 | 
				
			||||||
@@ -218,7 +218,7 @@ const props = defineProps({
 | 
				
			|||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const submitLabel = computed(() => {
 | 
					const submitLabel = computed(() => {
 | 
				
			||||||
  return props.submitLabel || t('globals.buttons.save')
 | 
					  return props.submitLabel || t('globals.messages.save')
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
let holidays = reactive([])
 | 
					let holidays = reactive([])
 | 
				
			||||||
@@ -231,9 +231,16 @@ const { t } = useI18n()
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
const form = useForm({
 | 
					const form = useForm({
 | 
				
			||||||
  validationSchema: toTypedSchema(createFormSchema(t)),
 | 
					  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 = () => {
 | 
					const saveHoliday = () => {
 | 
				
			||||||
  holidays.push({
 | 
					  holidays.push({
 | 
				
			||||||
    name: holidayName.value,
 | 
					    name: holidayName.value,
 | 
				
			||||||
@@ -252,21 +259,15 @@ const deleteHoliday = (item) => {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const handleDayToggle = (day, checked) => {
 | 
					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]) {
 | 
					  syncHoursToForm()
 | 
				
			||||||
    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 })
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const updateHours = (day, type, value) => {
 | 
					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] = { open: '09:00', close: '17:00' }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  hours.value[day][type] = value
 | 
					  hours.value[day][type] = value
 | 
				
			||||||
 | 
					  syncHoursToForm()
 | 
				
			||||||
  // Sync with form values
 | 
					 | 
				
			||||||
  form.setFieldValue('hours', { ...hours.value })
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const onSubmit = form.handleSubmit((values) => {
 | 
					const onSubmit = form.handleSubmit((values) => {
 | 
				
			||||||
  const businessHours =
 | 
					  const businessHours = values.is_always_open === true ? {} : { ...hours.value }
 | 
				
			||||||
    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 finalValues = {
 | 
					  const finalValues = {
 | 
				
			||||||
    ...values,
 | 
					    ...values,
 | 
				
			||||||
    is_always_open: values.is_always_open,
 | 
					 | 
				
			||||||
    hours: businessHours,
 | 
					    hours: businessHours,
 | 
				
			||||||
    holidays: holidays
 | 
					    holidays: [...holidays]
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  props.submitForm(finalValues)
 | 
					  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 for initial values
 | 
				
			||||||
watch(
 | 
					watch(() => props.initialValues, initializeFromValues, { immediate: true, deep: true })
 | 
				
			||||||
  () => 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 }
 | 
					 | 
				
			||||||
)
 | 
					 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -6,28 +6,28 @@ export const createColumns = (t) => [
 | 
				
			|||||||
    {
 | 
					    {
 | 
				
			||||||
        accessorKey: 'name',
 | 
					        accessorKey: 'name',
 | 
				
			||||||
        header: function () {
 | 
					        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 }) {
 | 
					        cell: function ({ row }) {
 | 
				
			||||||
            return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
 | 
					            return h('div', { class: 'text-center' }, row.getValue('name'))
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        accessorKey: 'created_at',
 | 
					        accessorKey: 'created_at',
 | 
				
			||||||
        header: function () {
 | 
					        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 }) {
 | 
					        cell: function ({ row }) {
 | 
				
			||||||
            return h('div', { class: 'text-center font-medium' }, format(row.getValue('created_at'), 'PPpp'))
 | 
					            return h('div', { class: 'text-center' }, format(row.getValue('created_at'), 'PPpp'))
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        accessorKey: 'updated_at',
 | 
					        accessorKey: 'updated_at',
 | 
				
			||||||
        header: function () {
 | 
					        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 }) {
 | 
					        cell: function ({ row }) {
 | 
				
			||||||
            return h('div', { class: 'text-center font-medium' }, format(row.getValue('updated_at'), 'PPpp'))
 | 
					            return h('div', { class: 'text-center' }, format(row.getValue('updated_at'), 'PPpp'))
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -8,10 +8,10 @@
 | 
				
			|||||||
    </DropdownMenuTrigger>
 | 
					    </DropdownMenuTrigger>
 | 
				
			||||||
    <DropdownMenuContent>
 | 
					    <DropdownMenuContent>
 | 
				
			||||||
      <DropdownMenuItem @click="edit(props.role.id)">
 | 
					      <DropdownMenuItem @click="edit(props.role.id)">
 | 
				
			||||||
        {{ t('globals.buttons.edit') }}
 | 
					        {{ t('globals.messages.edit') }}
 | 
				
			||||||
      </DropdownMenuItem>
 | 
					      </DropdownMenuItem>
 | 
				
			||||||
      <DropdownMenuItem @click="() => (alertOpen = true)">
 | 
					      <DropdownMenuItem @click="() => (alertOpen = true)">
 | 
				
			||||||
        {{ t('globals.buttons.delete') }}
 | 
					        {{ t('globals.messages.delete') }}
 | 
				
			||||||
      </DropdownMenuItem>
 | 
					      </DropdownMenuItem>
 | 
				
			||||||
    </DropdownMenuContent>
 | 
					    </DropdownMenuContent>
 | 
				
			||||||
  </DropdownMenu>
 | 
					  </DropdownMenu>
 | 
				
			||||||
@@ -23,15 +23,19 @@
 | 
				
			|||||||
          {{ t('globals.messages.areYouAbsolutelySure') }}
 | 
					          {{ t('globals.messages.areYouAbsolutelySure') }}
 | 
				
			||||||
        </AlertDialogTitle>
 | 
					        </AlertDialogTitle>
 | 
				
			||||||
        <AlertDialogDescription>
 | 
					        <AlertDialogDescription>
 | 
				
			||||||
          {{ t('admin.businessHours.deleteConfirmation') }}
 | 
					          {{
 | 
				
			||||||
 | 
					            t('globals.messages.deletionConfirmation', {
 | 
				
			||||||
 | 
					              name: t('globals.terms.businessHour').toLowerCase()
 | 
				
			||||||
 | 
					            })
 | 
				
			||||||
 | 
					          }}
 | 
				
			||||||
        </AlertDialogDescription>
 | 
					        </AlertDialogDescription>
 | 
				
			||||||
      </AlertDialogHeader>
 | 
					      </AlertDialogHeader>
 | 
				
			||||||
      <AlertDialogFooter>
 | 
					      <AlertDialogFooter>
 | 
				
			||||||
        <AlertDialogCancel>
 | 
					        <AlertDialogCancel>
 | 
				
			||||||
          {{ t('globals.buttons.cancel') }}
 | 
					          {{ t('globals.messages.cancel') }}
 | 
				
			||||||
        </AlertDialogCancel>
 | 
					        </AlertDialogCancel>
 | 
				
			||||||
        <AlertDialogAction @click="handleDelete">
 | 
					        <AlertDialogAction @click="handleDelete">
 | 
				
			||||||
          {{ t('globals.buttons.delete') }}
 | 
					          {{ t('globals.messages.delete') }}
 | 
				
			||||||
        </AlertDialogAction>
 | 
					        </AlertDialogAction>
 | 
				
			||||||
      </AlertDialogFooter>
 | 
					      </AlertDialogFooter>
 | 
				
			||||||
    </AlertDialogContent>
 | 
					    </AlertDialogContent>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -5,7 +5,7 @@ const timeRegex = /^([01]\d|2[0-3]):([0-5]\d)$/
 | 
				
			|||||||
export const createFormSchema = (t) => z.object({
 | 
					export const createFormSchema = (t) => z.object({
 | 
				
			||||||
    name: z.string().min(1, t('globals.messages.required')),
 | 
					    name: z.string().min(1, t('globals.messages.required')),
 | 
				
			||||||
    description: 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(
 | 
					    hours: z.record(
 | 
				
			||||||
        z.object({
 | 
					        z.object({
 | 
				
			||||||
            open: z.string().regex(timeRegex, t('form.error.time.invalid')),
 | 
					            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) {
 | 
					        if (!data.hours || Object.keys(data.hours).length === 0) {
 | 
				
			||||||
            ctx.addIssue({
 | 
					            ctx.addIssue({
 | 
				
			||||||
                code: z.ZodIssueCode.custom,
 | 
					                code: z.ZodIssueCode.custom,
 | 
				
			||||||
                message: t('admin.business_hours.hours.required'),
 | 
					                message: t('globals.messages.required'),
 | 
				
			||||||
                path: ['hours']
 | 
					                path: ['hours']
 | 
				
			||||||
            })
 | 
					            })
 | 
				
			||||||
        } else {
 | 
					        } else {
 | 
				
			||||||
@@ -25,7 +25,7 @@ export const createFormSchema = (t) => z.object({
 | 
				
			|||||||
                if (!data.hours[day].open || !data.hours[day].close) {
 | 
					                if (!data.hours[day].open || !data.hours[day].close) {
 | 
				
			||||||
                    ctx.addIssue({
 | 
					                    ctx.addIssue({
 | 
				
			||||||
                        code: z.ZodIssueCode.custom,
 | 
					                        code: z.ZodIssueCode.custom,
 | 
				
			||||||
                        message: t('admin.business_hours.open_close.required'),
 | 
					                        message: t('globals.messages.required'),
 | 
				
			||||||
                        path: ['hours', day]
 | 
					                        path: ['hours', day]
 | 
				
			||||||
                    })
 | 
					                    })
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2,7 +2,7 @@
 | 
				
			|||||||
  <form class="space-y-6 w-full">
 | 
					  <form class="space-y-6 w-full">
 | 
				
			||||||
    <FormField v-slot="{ componentField }" name="applies_to">
 | 
					    <FormField v-slot="{ componentField }" name="applies_to">
 | 
				
			||||||
      <FormItem>
 | 
					      <FormItem>
 | 
				
			||||||
        <FormLabel>{{ $t('form.field.appliesTo') }}</FormLabel>
 | 
					        <FormLabel>{{ $t('globals.terms.appliesTo') }}</FormLabel>
 | 
				
			||||||
        <FormControl>
 | 
					        <FormControl>
 | 
				
			||||||
          <Select v-bind="componentField" :modelValue="componentField.modelValue">
 | 
					          <Select v-bind="componentField" :modelValue="componentField.modelValue">
 | 
				
			||||||
            <SelectTrigger>
 | 
					            <SelectTrigger>
 | 
				
			||||||
@@ -27,7 +27,7 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    <FormField v-slot="{ componentField }" name="name">
 | 
					    <FormField v-slot="{ componentField }" name="name">
 | 
				
			||||||
      <FormItem>
 | 
					      <FormItem>
 | 
				
			||||||
        <FormLabel>{{ $t('form.field.name') }}</FormLabel>
 | 
					        <FormLabel>{{ $t('globals.terms.name') }}</FormLabel>
 | 
				
			||||||
        <FormControl>
 | 
					        <FormControl>
 | 
				
			||||||
          <Input type="text" placeholder="" v-bind="componentField" />
 | 
					          <Input type="text" placeholder="" v-bind="componentField" />
 | 
				
			||||||
        </FormControl>
 | 
					        </FormControl>
 | 
				
			||||||
@@ -38,7 +38,7 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    <FormField v-slot="{ componentField }" name="key">
 | 
					    <FormField v-slot="{ componentField }" name="key">
 | 
				
			||||||
      <FormItem>
 | 
					      <FormItem>
 | 
				
			||||||
        <FormLabel>{{ $t('form.field.key') }}</FormLabel>
 | 
					        <FormLabel>{{ $t('globals.terms.key') }}</FormLabel>
 | 
				
			||||||
        <FormControl>
 | 
					        <FormControl>
 | 
				
			||||||
          <Input
 | 
					          <Input
 | 
				
			||||||
            type="text"
 | 
					            type="text"
 | 
				
			||||||
@@ -53,7 +53,7 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    <FormField v-slot="{ componentField }" name="description">
 | 
					    <FormField v-slot="{ componentField }" name="description">
 | 
				
			||||||
      <FormItem>
 | 
					      <FormItem>
 | 
				
			||||||
        <FormLabel>{{ $t('form.field.description') }}</FormLabel>
 | 
					        <FormLabel>{{ $t('globals.terms.description') }}</FormLabel>
 | 
				
			||||||
        <FormControl>
 | 
					        <FormControl>
 | 
				
			||||||
          <Input type="text" v-bind="componentField" />
 | 
					          <Input type="text" v-bind="componentField" />
 | 
				
			||||||
        </FormControl>
 | 
					        </FormControl>
 | 
				
			||||||
@@ -64,9 +64,9 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    <FormField v-slot="{ componentField }" name="data_type">
 | 
					    <FormField v-slot="{ componentField }" name="data_type">
 | 
				
			||||||
      <FormItem>
 | 
					      <FormItem>
 | 
				
			||||||
        <FormLabel>{{ $t('form.field.type') }}</FormLabel>
 | 
					        <FormLabel>{{ $t('globals.terms.type') }}</FormLabel>
 | 
				
			||||||
        <FormControl>
 | 
					        <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>
 | 
					            <SelectTrigger>
 | 
				
			||||||
              <SelectValue />
 | 
					              <SelectValue />
 | 
				
			||||||
            </SelectTrigger>
 | 
					            </SelectTrigger>
 | 
				
			||||||
@@ -90,7 +90,7 @@
 | 
				
			|||||||
    <FormField name="values" v-slot="{ componentField, handleChange }">
 | 
					    <FormField name="values" v-slot="{ componentField, handleChange }">
 | 
				
			||||||
      <FormItem v-show="form.values.data_type === 'list'">
 | 
					      <FormItem v-show="form.values.data_type === 'list'">
 | 
				
			||||||
        <FormLabel>
 | 
					        <FormLabel>
 | 
				
			||||||
          {{ $t('form.field.listValues') }}
 | 
					          {{ $t('globals.terms.listValues') }}
 | 
				
			||||||
        </FormLabel>
 | 
					        </FormLabel>
 | 
				
			||||||
        <FormControl>
 | 
					        <FormControl>
 | 
				
			||||||
          <TagsInput :modelValue="componentField.modelValue" @update:modelValue="handleChange">
 | 
					          <TagsInput :modelValue="componentField.modelValue" @update:modelValue="handleChange">
 | 
				
			||||||
@@ -108,7 +108,9 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    <FormField name="regex" v-slot="{ componentField }">
 | 
					    <FormField name="regex" v-slot="{ componentField }">
 | 
				
			||||||
      <FormItem v-show="form.values.data_type === 'text'">
 | 
					      <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>
 | 
					        <FormControl>
 | 
				
			||||||
          <Input type="text" v-bind="componentField" />
 | 
					          <Input type="text" v-bind="componentField" />
 | 
				
			||||||
        </FormControl>
 | 
					        </FormControl>
 | 
				
			||||||
@@ -121,7 +123,9 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    <FormField name="regex_hint" v-slot="{ componentField }">
 | 
					    <FormField name="regex_hint" v-slot="{ componentField }">
 | 
				
			||||||
      <FormItem v-show="form.values.data_type === 'text'">
 | 
					      <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>
 | 
					        <FormControl>
 | 
				
			||||||
          <Input type="text" v-bind="componentField" />
 | 
					          <Input type="text" v-bind="componentField" />
 | 
				
			||||||
        </FormControl>
 | 
					        </FormControl>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -6,48 +6,48 @@ export const createColumns = (t) => [
 | 
				
			|||||||
    {
 | 
					    {
 | 
				
			||||||
        accessorKey: 'name',
 | 
					        accessorKey: 'name',
 | 
				
			||||||
        header: function () {
 | 
					        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 }) {
 | 
					        cell: function ({ row }) {
 | 
				
			||||||
            return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
 | 
					            return h('div', { class: 'text-center' }, row.getValue('name'))
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        accessorKey: 'key',
 | 
					        accessorKey: 'key',
 | 
				
			||||||
        header: function () {
 | 
					        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 }) {
 | 
					        cell: function ({ row }) {
 | 
				
			||||||
            return h('div', { class: 'text-center font-medium' }, row.getValue('key'))
 | 
					            return h('div', { class: 'text-center' }, row.getValue('key'))
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        accessorKey: 'data_type',
 | 
					        accessorKey: 'data_type',
 | 
				
			||||||
        header: function () {
 | 
					        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 }) {
 | 
					        cell: function ({ row }) {
 | 
				
			||||||
            return h('div', { class: 'text-center font-medium' }, row.getValue('data_type'))
 | 
					            return h('div', { class: 'text-center' }, row.getValue('data_type'))
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        accessorKey: 'applies_to',
 | 
					        accessorKey: 'applies_to',
 | 
				
			||||||
        header: function () {
 | 
					        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 }) {
 | 
					        cell: function ({ row }) {
 | 
				
			||||||
            return h('div', { class: 'text-center font-medium' }, row.getValue('applies_to'))
 | 
					            return h('div', { class: 'text-center' }, row.getValue('applies_to'))
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        accessorKey: 'created_at',
 | 
					        accessorKey: 'created_at',
 | 
				
			||||||
        header: function () {
 | 
					        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 }) {
 | 
					        cell: function ({ row }) {
 | 
				
			||||||
            return h(
 | 
					            return h(
 | 
				
			||||||
                'div',
 | 
					                'div',
 | 
				
			||||||
                { class: 'text-center font-medium' },
 | 
					                { class: 'text-center' },
 | 
				
			||||||
                format(row.getValue('created_at'), 'PPpp')
 | 
					                format(row.getValue('created_at'), 'PPpp')
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
@@ -55,12 +55,12 @@ export const createColumns = (t) => [
 | 
				
			|||||||
    {
 | 
					    {
 | 
				
			||||||
        accessorKey: 'updated_at',
 | 
					        accessorKey: 'updated_at',
 | 
				
			||||||
        header: function () {
 | 
					        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 }) {
 | 
					        cell: function ({ row }) {
 | 
				
			||||||
            return h(
 | 
					            return h(
 | 
				
			||||||
                'div',
 | 
					                'div',
 | 
				
			||||||
                { class: 'text-center font-medium' },
 | 
					                { class: 'text-center' },
 | 
				
			||||||
                format(row.getValue('updated_at'), 'PPpp')
 | 
					                format(row.getValue('updated_at'), 'PPpp')
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -8,10 +8,10 @@
 | 
				
			|||||||
    </DropdownMenuTrigger>
 | 
					    </DropdownMenuTrigger>
 | 
				
			||||||
    <DropdownMenuContent>
 | 
					    <DropdownMenuContent>
 | 
				
			||||||
      <DropdownMenuItem @click="editCustomAttribute">
 | 
					      <DropdownMenuItem @click="editCustomAttribute">
 | 
				
			||||||
        {{ $t('globals.buttons.edit') }}
 | 
					        {{ $t('globals.messages.edit') }}
 | 
				
			||||||
      </DropdownMenuItem>
 | 
					      </DropdownMenuItem>
 | 
				
			||||||
      <DropdownMenuItem @click="() => (alertOpen = true)">
 | 
					      <DropdownMenuItem @click="() => (alertOpen = true)">
 | 
				
			||||||
        {{ $t('globals.buttons.delete') }}
 | 
					        {{ $t('globals.messages.delete') }}
 | 
				
			||||||
      </DropdownMenuItem>
 | 
					      </DropdownMenuItem>
 | 
				
			||||||
    </DropdownMenuContent>
 | 
					    </DropdownMenuContent>
 | 
				
			||||||
  </DropdownMenu>
 | 
					  </DropdownMenu>
 | 
				
			||||||
@@ -21,13 +21,15 @@
 | 
				
			|||||||
      <AlertDialogHeader>
 | 
					      <AlertDialogHeader>
 | 
				
			||||||
        <AlertDialogTitle>{{ $t('globals.messages.areYouAbsolutelySure') }}</AlertDialogTitle>
 | 
					        <AlertDialogTitle>{{ $t('globals.messages.areYouAbsolutelySure') }}</AlertDialogTitle>
 | 
				
			||||||
        <AlertDialogDescription>{{
 | 
					        <AlertDialogDescription>{{
 | 
				
			||||||
          $t('admin.customAttributes.deleteConfirmation')
 | 
					          $t('globals.messages.deletionConfirmation', {
 | 
				
			||||||
 | 
					            name: $t('globals.terms.customAttribute').toLowerCase()
 | 
				
			||||||
 | 
					          })
 | 
				
			||||||
        }}</AlertDialogDescription>
 | 
					        }}</AlertDialogDescription>
 | 
				
			||||||
      </AlertDialogHeader>
 | 
					      </AlertDialogHeader>
 | 
				
			||||||
      <AlertDialogFooter>
 | 
					      <AlertDialogFooter>
 | 
				
			||||||
        <AlertDialogCancel>{{ $t('globals.buttons.cancel') }}</AlertDialogCancel>
 | 
					        <AlertDialogCancel>{{ $t('globals.messages.cancel') }}</AlertDialogCancel>
 | 
				
			||||||
        <AlertDialogAction @click="handleDelete">
 | 
					        <AlertDialogAction @click="handleDelete">
 | 
				
			||||||
          {{ $t('globals.buttons.delete') }}
 | 
					          {{ $t('globals.messages.delete') }}
 | 
				
			||||||
        </AlertDialogAction>
 | 
					        </AlertDialogAction>
 | 
				
			||||||
      </AlertDialogFooter>
 | 
					      </AlertDialogFooter>
 | 
				
			||||||
    </AlertDialogContent>
 | 
					    </AlertDialogContent>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -15,7 +15,7 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    <FormField v-slot="{ componentField }" name="lang">
 | 
					    <FormField v-slot="{ componentField }" name="lang">
 | 
				
			||||||
      <FormItem>
 | 
					      <FormItem>
 | 
				
			||||||
        <FormLabel>{{ t('admin.general.language') }}</FormLabel>
 | 
					        <FormLabel>{{ t('globals.terms.language') }}</FormLabel>
 | 
				
			||||||
        <FormControl>
 | 
					        <FormControl>
 | 
				
			||||||
          <Select v-bind="componentField" :modelValue="componentField.modelValue">
 | 
					          <Select v-bind="componentField" :modelValue="componentField.modelValue">
 | 
				
			||||||
            <SelectTrigger>
 | 
					            <SelectTrigger>
 | 
				
			||||||
@@ -39,7 +39,7 @@
 | 
				
			|||||||
    <FormField v-slot="{ componentField }" name="timezone">
 | 
					    <FormField v-slot="{ componentField }" name="timezone">
 | 
				
			||||||
      <FormItem>
 | 
					      <FormItem>
 | 
				
			||||||
        <FormLabel>
 | 
					        <FormLabel>
 | 
				
			||||||
          {{ t('admin.general.timezone') }}
 | 
					          {{ t('globals.terms.timezone') }}
 | 
				
			||||||
        </FormLabel>
 | 
					        </FormLabel>
 | 
				
			||||||
        <FormControl>
 | 
					        <FormControl>
 | 
				
			||||||
          <Select v-bind="componentField">
 | 
					          <Select v-bind="componentField">
 | 
				
			||||||
@@ -91,7 +91,7 @@
 | 
				
			|||||||
    <FormField v-slot="{ field }" name="root_url">
 | 
					    <FormField v-slot="{ field }" name="root_url">
 | 
				
			||||||
      <FormItem>
 | 
					      <FormItem>
 | 
				
			||||||
        <FormLabel>
 | 
					        <FormLabel>
 | 
				
			||||||
          {{ t('admin.general.rootURL') }}
 | 
					          {{ t('globals.terms.rootURL') }}
 | 
				
			||||||
        </FormLabel>
 | 
					        </FormLabel>
 | 
				
			||||||
        <FormControl>
 | 
					        <FormControl>
 | 
				
			||||||
          <Input type="text" placeholder="" v-bind="field" />
 | 
					          <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({
 | 
					const form = useForm({
 | 
				
			||||||
  validationSchema: toTypedSchema(createFormSchema(t))
 | 
					  validationSchema: toTypedSchema(createFormSchema(t))
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
@@ -248,18 +248,10 @@ const fetchBusinessHours = async () => {
 | 
				
			|||||||
    })
 | 
					    })
 | 
				
			||||||
    businessHours.value = response.data.data
 | 
					    businessHours.value = response.data.data
 | 
				
			||||||
  } catch (error) {
 | 
					  } catch (error) {
 | 
				
			||||||
    // If unauthorized (no permission), show a toast message.
 | 
					    emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
				
			||||||
    if (error.response.status === 403) {
 | 
					      variant: 'destructive',
 | 
				
			||||||
      emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
					      description: handleHTTPError(error).message
 | 
				
			||||||
        variant: 'destructive',
 | 
					    })
 | 
				
			||||||
        description: t('admin.businessHours.unauthorized')
 | 
					 | 
				
			||||||
      })
 | 
					 | 
				
			||||||
    } else {
 | 
					 | 
				
			||||||
      emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
					 | 
				
			||||||
        variant: 'destructive',
 | 
					 | 
				
			||||||
        description: handleHTTPError(error).message
 | 
					 | 
				
			||||||
      })
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -3,7 +3,7 @@
 | 
				
			|||||||
    <!-- Basic Fields -->
 | 
					    <!-- Basic Fields -->
 | 
				
			||||||
    <FormField v-slot="{ componentField }" name="name">
 | 
					    <FormField v-slot="{ componentField }" name="name">
 | 
				
			||||||
      <FormItem>
 | 
					      <FormItem>
 | 
				
			||||||
        <FormLabel>{{ $t('form.field.name') }}</FormLabel>
 | 
					        <FormLabel>{{ $t('globals.terms.name') }}</FormLabel>
 | 
				
			||||||
        <FormControl>
 | 
					        <FormControl>
 | 
				
			||||||
          <Input type="text" placeholder="" v-bind="componentField" />
 | 
					          <Input type="text" placeholder="" v-bind="componentField" />
 | 
				
			||||||
        </FormControl>
 | 
					        </FormControl>
 | 
				
			||||||
@@ -14,7 +14,7 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    <FormField v-slot="{ componentField }" name="from">
 | 
					    <FormField v-slot="{ componentField }" name="from">
 | 
				
			||||||
      <FormItem>
 | 
					      <FormItem>
 | 
				
			||||||
        <FormLabel>{{ $t('form.field.fromEmailAddress') }}</FormLabel>
 | 
					        <FormLabel>{{ $t('globals.terms.fromEmailAddress') }}</FormLabel>
 | 
				
			||||||
        <FormControl>
 | 
					        <FormControl>
 | 
				
			||||||
          <Input
 | 
					          <Input
 | 
				
			||||||
            type="text"
 | 
					            type="text"
 | 
				
			||||||
@@ -33,7 +33,7 @@
 | 
				
			|||||||
    <FormField v-slot="{ componentField, handleChange }" name="enabled">
 | 
					    <FormField v-slot="{ componentField, handleChange }" name="enabled">
 | 
				
			||||||
      <FormItem class="flex flex-row items-center justify-between box p-4">
 | 
					      <FormItem class="flex flex-row items-center justify-between box p-4">
 | 
				
			||||||
        <div class="space-y-0.5">
 | 
					        <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>
 | 
					          <FormDescription>{{ $t('admin.inbox.enabled.description') }}</FormDescription>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
        <FormControl>
 | 
					        <FormControl>
 | 
				
			||||||
@@ -73,7 +73,7 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
      <FormField v-slot="{ componentField }" name="imap.port">
 | 
					      <FormField v-slot="{ componentField }" name="imap.port">
 | 
				
			||||||
        <FormItem>
 | 
					        <FormItem>
 | 
				
			||||||
          <FormLabel>{{ $t('form.field.port') }}</FormLabel>
 | 
					          <FormLabel>{{ $t('globals.terms.port') }}</FormLabel>
 | 
				
			||||||
          <FormControl>
 | 
					          <FormControl>
 | 
				
			||||||
            <Input type="number" placeholder="993" v-bind="componentField" />
 | 
					            <Input type="number" placeholder="993" v-bind="componentField" />
 | 
				
			||||||
          </FormControl>
 | 
					          </FormControl>
 | 
				
			||||||
@@ -100,7 +100,7 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
      <FormField v-slot="{ componentField }" name="imap.username">
 | 
					      <FormField v-slot="{ componentField }" name="imap.username">
 | 
				
			||||||
        <FormItem>
 | 
					        <FormItem>
 | 
				
			||||||
          <FormLabel>{{ $t('form.field.username') }}</FormLabel>
 | 
					          <FormLabel>{{ $t('globals.terms.username') }}</FormLabel>
 | 
				
			||||||
          <FormControl>
 | 
					          <FormControl>
 | 
				
			||||||
            <Input type="text" placeholder="inbox@example.com" v-bind="componentField" />
 | 
					            <Input type="text" placeholder="inbox@example.com" v-bind="componentField" />
 | 
				
			||||||
          </FormControl>
 | 
					          </FormControl>
 | 
				
			||||||
@@ -110,7 +110,7 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
      <FormField v-slot="{ componentField }" name="imap.password">
 | 
					      <FormField v-slot="{ componentField }" name="imap.password">
 | 
				
			||||||
        <FormItem>
 | 
					        <FormItem>
 | 
				
			||||||
          <FormLabel>{{ $t('form.field.password') }}</FormLabel>
 | 
					          <FormLabel>{{ $t('globals.terms.password') }}</FormLabel>
 | 
				
			||||||
          <FormControl>
 | 
					          <FormControl>
 | 
				
			||||||
            <Input type="password" placeholder="••••••••" v-bind="componentField" />
 | 
					            <Input type="password" placeholder="••••••••" v-bind="componentField" />
 | 
				
			||||||
          </FormControl>
 | 
					          </FormControl>
 | 
				
			||||||
@@ -124,7 +124,7 @@
 | 
				
			|||||||
          <FormControl>
 | 
					          <FormControl>
 | 
				
			||||||
            <Select v-bind="componentField">
 | 
					            <Select v-bind="componentField">
 | 
				
			||||||
              <SelectTrigger>
 | 
					              <SelectTrigger>
 | 
				
			||||||
                <SelectValue :placeholder="t('form.field.selectTLS')" />
 | 
					                <SelectValue :placeholder="t('globals.messages.selectTLS')" />
 | 
				
			||||||
              </SelectTrigger>
 | 
					              </SelectTrigger>
 | 
				
			||||||
              <SelectContent>
 | 
					              <SelectContent>
 | 
				
			||||||
                <SelectItem value="none">OFF</SelectItem>
 | 
					                <SelectItem value="none">OFF</SelectItem>
 | 
				
			||||||
@@ -185,7 +185,7 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
      <FormField v-slot="{ componentField }" name="smtp.host">
 | 
					      <FormField v-slot="{ componentField }" name="smtp.host">
 | 
				
			||||||
        <FormItem>
 | 
					        <FormItem>
 | 
				
			||||||
          <FormLabel>{{ $t('form.field.host') }}</FormLabel>
 | 
					          <FormLabel>{{ $t('globals.terms.host') }}</FormLabel>
 | 
				
			||||||
          <FormControl>
 | 
					          <FormControl>
 | 
				
			||||||
            <Input type="text" placeholder="smtp.gmail.com" v-bind="componentField" />
 | 
					            <Input type="text" placeholder="smtp.gmail.com" v-bind="componentField" />
 | 
				
			||||||
          </FormControl>
 | 
					          </FormControl>
 | 
				
			||||||
@@ -195,7 +195,7 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
      <FormField v-slot="{ componentField }" name="smtp.port">
 | 
					      <FormField v-slot="{ componentField }" name="smtp.port">
 | 
				
			||||||
        <FormItem>
 | 
					        <FormItem>
 | 
				
			||||||
          <FormLabel>{{ $t('form.field.port') }}</FormLabel>
 | 
					          <FormLabel>{{ $t('globals.terms.port') }}</FormLabel>
 | 
				
			||||||
          <FormControl>
 | 
					          <FormControl>
 | 
				
			||||||
            <Input type="number" placeholder="587" v-bind="componentField" />
 | 
					            <Input type="number" placeholder="587" v-bind="componentField" />
 | 
				
			||||||
          </FormControl>
 | 
					          </FormControl>
 | 
				
			||||||
@@ -205,7 +205,7 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
      <FormField v-slot="{ componentField }" name="smtp.username">
 | 
					      <FormField v-slot="{ componentField }" name="smtp.username">
 | 
				
			||||||
        <FormItem>
 | 
					        <FormItem>
 | 
				
			||||||
          <FormLabel>{{ $t('form.field.username') }}</FormLabel>
 | 
					          <FormLabel>{{ $t('globals.terms.username') }}</FormLabel>
 | 
				
			||||||
          <FormControl>
 | 
					          <FormControl>
 | 
				
			||||||
            <Input type="text" placeholder="user@example.com" v-bind="componentField" />
 | 
					            <Input type="text" placeholder="user@example.com" v-bind="componentField" />
 | 
				
			||||||
          </FormControl>
 | 
					          </FormControl>
 | 
				
			||||||
@@ -215,7 +215,7 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
      <FormField v-slot="{ componentField }" name="smtp.password">
 | 
					      <FormField v-slot="{ componentField }" name="smtp.password">
 | 
				
			||||||
        <FormItem>
 | 
					        <FormItem>
 | 
				
			||||||
          <FormLabel>{{ $t('form.field.password') }}</FormLabel>
 | 
					          <FormLabel>{{ $t('globals.terms.password') }}</FormLabel>
 | 
				
			||||||
          <FormControl>
 | 
					          <FormControl>
 | 
				
			||||||
            <Input type="password" placeholder="••••••••" v-bind="componentField" />
 | 
					            <Input type="password" placeholder="••••••••" v-bind="componentField" />
 | 
				
			||||||
          </FormControl>
 | 
					          </FormControl>
 | 
				
			||||||
@@ -296,7 +296,7 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
      <FormField v-slot="{ componentField }" name="smtp.tls_type">
 | 
					      <FormField v-slot="{ componentField }" name="smtp.tls_type">
 | 
				
			||||||
        <FormItem>
 | 
					        <FormItem>
 | 
				
			||||||
          <FormLabel>{{ t('admin.inbox.tls') }}</FormLabel>
 | 
					          <FormLabel>{{ t('globals.terms.tls') }}</FormLabel>
 | 
				
			||||||
          <FormControl>
 | 
					          <FormControl>
 | 
				
			||||||
            <Select v-bind="componentField">
 | 
					            <Select v-bind="componentField">
 | 
				
			||||||
              <SelectTrigger>
 | 
					              <SelectTrigger>
 | 
				
			||||||
@@ -398,7 +398,7 @@ const form = useForm({
 | 
				
			|||||||
  initialValues: {
 | 
					  initialValues: {
 | 
				
			||||||
    name: '',
 | 
					    name: '',
 | 
				
			||||||
    from: '',
 | 
					    from: '',
 | 
				
			||||||
    enabled: false,
 | 
					    enabled: true,
 | 
				
			||||||
    csat_enabled: false,
 | 
					    csat_enabled: false,
 | 
				
			||||||
    imap: {
 | 
					    imap: {
 | 
				
			||||||
      host: 'imap.gmail.com',
 | 
					      host: 'imap.gmail.com',
 | 
				
			||||||
@@ -429,7 +429,7 @@ const form = useForm({
 | 
				
			|||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const submitLabel = computed(() => {
 | 
					const submitLabel = computed(() => {
 | 
				
			||||||
  return props.submitLabel || t('globals.buttons.save')
 | 
					  return props.submitLabel || t('globals.messages.save')
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const onSubmit = form.handleSubmit(async (values) => {
 | 
					const onSubmit = form.handleSubmit(async (values) => {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -8,16 +8,16 @@
 | 
				
			|||||||
    </DropdownMenuTrigger>
 | 
					    </DropdownMenuTrigger>
 | 
				
			||||||
    <DropdownMenuContent>
 | 
					    <DropdownMenuContent>
 | 
				
			||||||
      <DropdownMenuItem @click="editInbox(props.inbox.id)">{{
 | 
					      <DropdownMenuItem @click="editInbox(props.inbox.id)">{{
 | 
				
			||||||
        $t('globals.buttons.edit')
 | 
					        $t('globals.messages.edit')
 | 
				
			||||||
      }}</DropdownMenuItem>
 | 
					      }}</DropdownMenuItem>
 | 
				
			||||||
      <DropdownMenuItem @click="() => (alertOpen = true)">{{
 | 
					      <DropdownMenuItem @click="() => (alertOpen = true)">{{
 | 
				
			||||||
        $t('globals.buttons.delete')
 | 
					        $t('globals.messages.delete')
 | 
				
			||||||
      }}</DropdownMenuItem>
 | 
					      }}</DropdownMenuItem>
 | 
				
			||||||
      <DropdownMenuItem @click="toggleInbox(props.inbox.id)" v-if="props.inbox.enabled">
 | 
					      <DropdownMenuItem @click="toggleInbox(props.inbox.id)" v-if="props.inbox.enabled">
 | 
				
			||||||
        {{ $t('globals.buttons.disable') }}
 | 
					        {{ $t('globals.messages.disable') }}
 | 
				
			||||||
      </DropdownMenuItem>
 | 
					      </DropdownMenuItem>
 | 
				
			||||||
      <DropdownMenuItem @click="toggleInbox(props.inbox.id)" v-else>{{
 | 
					      <DropdownMenuItem @click="toggleInbox(props.inbox.id)" v-else>{{
 | 
				
			||||||
        $t('globals.buttons.enable')
 | 
					        $t('globals.messages.enable')
 | 
				
			||||||
      }}</DropdownMenuItem>
 | 
					      }}</DropdownMenuItem>
 | 
				
			||||||
    </DropdownMenuContent>
 | 
					    </DropdownMenuContent>
 | 
				
			||||||
  </DropdownMenu>
 | 
					  </DropdownMenu>
 | 
				
			||||||
@@ -27,13 +27,13 @@
 | 
				
			|||||||
      <AlertDialogHeader>
 | 
					      <AlertDialogHeader>
 | 
				
			||||||
        <AlertDialogTitle>{{ $t('globals.messages.areYouAbsolutelySure') }}</AlertDialogTitle>
 | 
					        <AlertDialogTitle>{{ $t('globals.messages.areYouAbsolutelySure') }}</AlertDialogTitle>
 | 
				
			||||||
        <AlertDialogDescription>
 | 
					        <AlertDialogDescription>
 | 
				
			||||||
          {{ $t('admin.inbox.deleteConfirmation') }}
 | 
					          {{ $t('globals.messages.deletionConfirmation', { name: $t('globals.terms.inbox').toLowerCase() }) }}
 | 
				
			||||||
        </AlertDialogDescription>
 | 
					        </AlertDialogDescription>
 | 
				
			||||||
      </AlertDialogHeader>
 | 
					      </AlertDialogHeader>
 | 
				
			||||||
      <AlertDialogFooter>
 | 
					      <AlertDialogFooter>
 | 
				
			||||||
        <AlertDialogCancel>{{ $t('globals.buttons.cancel') }}</AlertDialogCancel>
 | 
					        <AlertDialogCancel>{{ $t('globals.messages.cancel') }}</AlertDialogCancel>
 | 
				
			||||||
        <AlertDialogAction @click="handleDelete">{{
 | 
					        <AlertDialogAction @click="handleDelete">{{
 | 
				
			||||||
          $t('globals.buttons.delete')
 | 
					          $t('globals.messages.delete')
 | 
				
			||||||
        }}</AlertDialogAction>
 | 
					        }}</AlertDialogAction>
 | 
				
			||||||
      </AlertDialogFooter>
 | 
					      </AlertDialogFooter>
 | 
				
			||||||
    </AlertDialogContent>
 | 
					    </AlertDialogContent>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,125 +1,136 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <div class="space-y-5 rounded">
 | 
					  <div class="space-y-6">
 | 
				
			||||||
    <div class="space-y-5">
 | 
					    <!-- Empty State -->
 | 
				
			||||||
      <div v-for="(action, index) in model" :key="index" class="space-y-5">
 | 
					    <div
 | 
				
			||||||
        <hr v-if="index" class="border-t-2 border-dotted border-gray-300" />
 | 
					      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">
 | 
					    <!-- Actions List -->
 | 
				
			||||||
          <div class="flex items-center justify-between">
 | 
					    <div v-else class="space-y-6">
 | 
				
			||||||
            <div class="flex gap-5">
 | 
					      <div v-for="(action, index) in model" :key="index" class="relative">
 | 
				
			||||||
              <div class="w-48">
 | 
					        <!-- Action Card -->
 | 
				
			||||||
                <Select
 | 
					        <div class="border rounded p-6 shadow-sm hover:shadow-md transition-shadow">
 | 
				
			||||||
                  v-model="action.type"
 | 
					          <div class="flex items-start justify-between gap-4">
 | 
				
			||||||
                  @update:modelValue="(value) => updateField(value, index)"
 | 
					            <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>
 | 
					                  <label class="block text-sm font-medium mb-2">Value</label>
 | 
				
			||||||
                    <SelectValue :placeholder="config.typePlaceholder" />
 | 
					
 | 
				
			||||||
                  </SelectTrigger>
 | 
					                  <SelectComboBox
 | 
				
			||||||
                  <SelectContent>
 | 
					                    v-if="action.type === 'assign_user'"
 | 
				
			||||||
                    <SelectGroup>
 | 
					                    v-model="action.value[0]"
 | 
				
			||||||
                      <SelectItem
 | 
					                    :items="config.actions[action.type].options"
 | 
				
			||||||
                        v-for="(actionConfig, key) in config.actions"
 | 
					                    :placeholder="config.valuePlaceholder"
 | 
				
			||||||
                        :key="key"
 | 
					                    @update:modelValue="(value) => updateValue(value, index)"
 | 
				
			||||||
                        :value="key"
 | 
					                    type="user"
 | 
				
			||||||
                      >
 | 
					                  />
 | 
				
			||||||
                        {{ actionConfig.label }}
 | 
					
 | 
				
			||||||
                      </SelectItem>
 | 
					                  <SelectComboBox
 | 
				
			||||||
                    </SelectGroup>
 | 
					                    v-else-if="action.type === 'assign_team'"
 | 
				
			||||||
                  </SelectContent>
 | 
					                    v-model="action.value[0]"
 | 
				
			||||||
                </Select>
 | 
					                    :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>
 | 
					              </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					              <!-- Tag Selection -->
 | 
				
			||||||
              <div
 | 
					              <div
 | 
				
			||||||
                v-if="action.type && config.actions[action.type]?.type === 'select'"
 | 
					                v-if="action.type && config.actions[action.type]?.type === 'tag'"
 | 
				
			||||||
                class="w-48"
 | 
					                class="max-w-md"
 | 
				
			||||||
              >
 | 
					              >
 | 
				
			||||||
                <ComboBox
 | 
					                <label class="block text-sm font-medium mb-2">{{ $t('globals.terms.tag') }}</label>
 | 
				
			||||||
                  v-model="action.value[0]"
 | 
					                <SelectTag
 | 
				
			||||||
                  :items="config.actions[action.type].options"
 | 
					                  v-model="action.value"
 | 
				
			||||||
                  :placeholder="config.valuePlaceholder"
 | 
					                  :items="tagsStore.tagNames.map((tag) => ({ label: tag, value: tag }))"
 | 
				
			||||||
                  @update:modelValue="(value) => updateValue(value, index)"
 | 
					                  placeholder="Select tags"
 | 
				
			||||||
                >
 | 
					                />
 | 
				
			||||||
                  <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>
 | 
					 | 
				
			||||||
              </div>
 | 
					              </div>
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            <X class="cursor-pointer w-4" @click="remove(index)" />
 | 
					            <!-- Remove Button -->
 | 
				
			||||||
          </div>
 | 
					            <CloseButton :onClose="() => remove(index)" />
 | 
				
			||||||
 | 
					 | 
				
			||||||
          <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"
 | 
					 | 
				
			||||||
            />
 | 
					 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
        </div>
 | 
					        </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>
 | 
					    </div>
 | 
				
			||||||
    <Button type="button" variant="outline" @click.prevent="add">{{ config.addButtonText }}</Button>
 | 
					 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { Button } from '@/components/ui/button'
 | 
					import { Button } from '@/components/ui/button'
 | 
				
			||||||
import { X } from 'lucide-vue-next'
 | 
					import { Plus } from 'lucide-vue-next'
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  Select,
 | 
					  Select,
 | 
				
			||||||
  SelectContent,
 | 
					  SelectContent,
 | 
				
			||||||
@@ -128,10 +139,10 @@ import {
 | 
				
			|||||||
  SelectTrigger,
 | 
					  SelectTrigger,
 | 
				
			||||||
  SelectValue
 | 
					  SelectValue
 | 
				
			||||||
} from '@/components/ui/select'
 | 
					} 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 { SelectTag } from '@/components/ui/select'
 | 
				
			||||||
import { useTagStore } from '@/stores/tag'
 | 
					import { useTagStore } from '@/stores/tag'
 | 
				
			||||||
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
 | 
					import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const model = defineModel('actions', {
 | 
					const model = defineModel('actions', {
 | 
				
			||||||
  type: Array,
 | 
					  type: Array,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -3,7 +3,7 @@
 | 
				
			|||||||
  <form @submit="onSubmit" class="space-y-6 w-full" :class="{ 'opacity-50': formLoading }">
 | 
					  <form @submit="onSubmit" class="space-y-6 w-full" :class="{ 'opacity-50': formLoading }">
 | 
				
			||||||
    <FormField v-slot="{ componentField }" name="name">
 | 
					    <FormField v-slot="{ componentField }" name="name">
 | 
				
			||||||
      <FormItem>
 | 
					      <FormItem>
 | 
				
			||||||
        <FormLabel>{{ t('form.field.name') }} </FormLabel>
 | 
					        <FormLabel>{{ t('globals.terms.name') }} </FormLabel>
 | 
				
			||||||
        <FormControl>
 | 
					        <FormControl>
 | 
				
			||||||
          <Input type="text" placeholder="" v-bind="componentField" />
 | 
					          <Input type="text" placeholder="" v-bind="componentField" />
 | 
				
			||||||
        </FormControl>
 | 
					        </FormControl>
 | 
				
			||||||
@@ -19,7 +19,7 @@
 | 
				
			|||||||
            <Editor
 | 
					            <Editor
 | 
				
			||||||
              v-model:htmlContent="componentField.modelValue"
 | 
					              v-model:htmlContent="componentField.modelValue"
 | 
				
			||||||
              @update:htmlContent="(value) => componentField.onChange(value)"
 | 
					              @update:htmlContent="(value) => componentField.onChange(value)"
 | 
				
			||||||
              :placeholder="t('editor.placeholder')"
 | 
					              :placeholder="t('editor.newLine')"
 | 
				
			||||||
            />
 | 
					            />
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
        </FormControl>
 | 
					        </FormControl>
 | 
				
			||||||
@@ -27,9 +27,16 @@
 | 
				
			|||||||
      </FormItem>
 | 
					      </FormItem>
 | 
				
			||||||
    </FormField>
 | 
					    </FormField>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <FormField v-slot="{ componentField }" name="actions">
 | 
					    <FormField
 | 
				
			||||||
 | 
					      v-slot="{ componentField }"
 | 
				
			||||||
 | 
					      name="actions"
 | 
				
			||||||
 | 
					      :validate-on-blur="false"
 | 
				
			||||||
 | 
					      :validate-on-change="false"
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
      <FormItem>
 | 
					      <FormItem>
 | 
				
			||||||
        <FormLabel> {{ t('admin.macro.actions') }}</FormLabel>
 | 
					        <FormLabel>
 | 
				
			||||||
 | 
					          {{ t('globals.terms.action', 2) }} ({{ t('globals.terms.optional', 1).toLowerCase() }})
 | 
				
			||||||
 | 
					        </FormLabel>
 | 
				
			||||||
        <FormControl>
 | 
					        <FormControl>
 | 
				
			||||||
          <ActionBuilder
 | 
					          <ActionBuilder
 | 
				
			||||||
            v-model:actions="componentField.modelValue"
 | 
					            v-model:actions="componentField.modelValue"
 | 
				
			||||||
@@ -41,19 +48,59 @@
 | 
				
			|||||||
      </FormItem>
 | 
					      </FormItem>
 | 
				
			||||||
    </FormField>
 | 
					    </FormField>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <FormField v-slot="{ componentField }" name="visibility">
 | 
					    <FormField v-slot="{ componentField, handleChange }" name="visible_when">
 | 
				
			||||||
      <FormItem>
 | 
					      <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>
 | 
					        <FormControl>
 | 
				
			||||||
          <Select v-bind="componentField">
 | 
					          <Select v-bind="componentField">
 | 
				
			||||||
            <SelectTrigger>
 | 
					            <SelectTrigger>
 | 
				
			||||||
              <SelectValue placeholder="Select visibility" />
 | 
					              <SelectValue />
 | 
				
			||||||
            </SelectTrigger>
 | 
					            </SelectTrigger>
 | 
				
			||||||
            <SelectContent>
 | 
					            <SelectContent>
 | 
				
			||||||
              <SelectGroup>
 | 
					              <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="team">{{ t('globals.terms.team') }}</SelectItem>
 | 
				
			||||||
                <SelectItem value="user">{{ t('globals.terms.user') }}</SelectItem>
 | 
					                <SelectItem value="user">{{ t('globals.terms.agent') }}</SelectItem>
 | 
				
			||||||
              </SelectGroup>
 | 
					              </SelectGroup>
 | 
				
			||||||
            </SelectContent>
 | 
					            </SelectContent>
 | 
				
			||||||
          </Select>
 | 
					          </Select>
 | 
				
			||||||
@@ -64,29 +111,16 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    <FormField v-if="form.values.visibility === 'team'" v-slot="{ componentField }" name="team_id">
 | 
					    <FormField v-if="form.values.visibility === 'team'" v-slot="{ componentField }" name="team_id">
 | 
				
			||||||
      <FormItem>
 | 
					      <FormItem>
 | 
				
			||||||
        <FormLabel>{{ t('globals.terms.user') }}</FormLabel>
 | 
					        <FormLabel>{{ t('globals.terms.team') }}</FormLabel>
 | 
				
			||||||
        <FormControl>
 | 
					        <FormControl>
 | 
				
			||||||
          <ComboBox
 | 
					          <SelectComboBox
 | 
				
			||||||
            v-bind="componentField"
 | 
					            v-bind="componentField"
 | 
				
			||||||
            :items="tStore.options"
 | 
					            :items="tStore.options"
 | 
				
			||||||
            :placeholder="t('form.field.selectTeam')"
 | 
					            :placeholder="
 | 
				
			||||||
          >
 | 
					              t('globals.messages.select', { name: t('globals.terms.team').toLowerCase() })
 | 
				
			||||||
            <template #item="{ item }">
 | 
					            "
 | 
				
			||||||
              <div class="flex items-center gap-2 ml-2">
 | 
					            type="team"
 | 
				
			||||||
                <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>
 | 
					 | 
				
			||||||
        </FormControl>
 | 
					        </FormControl>
 | 
				
			||||||
        <FormMessage />
 | 
					        <FormMessage />
 | 
				
			||||||
      </FormItem>
 | 
					      </FormItem>
 | 
				
			||||||
@@ -94,35 +128,16 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    <FormField v-if="form.values.visibility === 'user'" v-slot="{ componentField }" name="user_id">
 | 
					    <FormField v-if="form.values.visibility === 'user'" v-slot="{ componentField }" name="user_id">
 | 
				
			||||||
      <FormItem>
 | 
					      <FormItem>
 | 
				
			||||||
        <FormLabel>{{ t('globals.terms.user') }}</FormLabel>
 | 
					        <FormLabel>{{ t('globals.terms.agent') }}</FormLabel>
 | 
				
			||||||
        <FormControl>
 | 
					        <FormControl>
 | 
				
			||||||
          <ComboBox
 | 
					          <SelectComboBox
 | 
				
			||||||
            v-bind="componentField"
 | 
					            v-bind="componentField"
 | 
				
			||||||
            :items="uStore.options"
 | 
					            :items="uStore.options"
 | 
				
			||||||
            :placeholder="t('form.field.selectUser')"
 | 
					            :placeholder="
 | 
				
			||||||
          >
 | 
					              t('globals.messages.select', { name: t('globals.terms.agent').toLowerCase() })
 | 
				
			||||||
            <template #item="{ item }">
 | 
					            "
 | 
				
			||||||
              <div class="flex items-center gap-2 ml-2">
 | 
					            type="user"
 | 
				
			||||||
                <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>
 | 
					 | 
				
			||||||
        </FormControl>
 | 
					        </FormControl>
 | 
				
			||||||
        <FormMessage />
 | 
					        <FormMessage />
 | 
				
			||||||
      </FormItem>
 | 
					      </FormItem>
 | 
				
			||||||
@@ -139,24 +154,24 @@ import { Button } from '@/components/ui/button'
 | 
				
			|||||||
import { Spinner } from '@/components/ui/spinner'
 | 
					import { Spinner } from '@/components/ui/spinner'
 | 
				
			||||||
import { Input } from '@/components/ui/input'
 | 
					import { Input } from '@/components/ui/input'
 | 
				
			||||||
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
 | 
					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 ActionBuilder from '@/features/admin/macros/ActionBuilder.vue'
 | 
				
			||||||
import { useConversationFilters } from '@/composables/useConversationFilters'
 | 
					import { useConversationFilters } from '@/composables/useConversationFilters'
 | 
				
			||||||
import { useUsersStore } from '@/stores/users'
 | 
					import { useUsersStore } from '@/stores/users'
 | 
				
			||||||
import { useTeamStore } from '@/stores/team'
 | 
					import { useTeamStore } from '@/stores/team'
 | 
				
			||||||
import { getTextFromHTML } from '@/utils/strings.js'
 | 
					import { getTextFromHTML } from '@/utils/strings.js'
 | 
				
			||||||
import { createFormSchema } from './formSchema.js'
 | 
					import { createFormSchema } from './formSchema.js'
 | 
				
			||||||
 | 
					import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  Select,
 | 
					  Select,
 | 
				
			||||||
  SelectContent,
 | 
					  SelectContent,
 | 
				
			||||||
  SelectGroup,
 | 
					  SelectGroup,
 | 
				
			||||||
  SelectItem,
 | 
					  SelectItem,
 | 
				
			||||||
  SelectTrigger,
 | 
					  SelectTrigger,
 | 
				
			||||||
  SelectValue
 | 
					  SelectValue,
 | 
				
			||||||
 | 
					  SelectTag
 | 
				
			||||||
} from '@/components/ui/select'
 | 
					} from '@/components/ui/select'
 | 
				
			||||||
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
 | 
					 | 
				
			||||||
import { useI18n } from 'vue-i18n'
 | 
					import { useI18n } from 'vue-i18n'
 | 
				
			||||||
import Editor from '@/features/conversation/ConversationTextEditor.vue'
 | 
					import Editor from '@/components/editor/TextEditor.vue'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const { macroActions } = useConversationFilters()
 | 
					const { macroActions } = useConversationFilters()
 | 
				
			||||||
const { t } = useI18n()
 | 
					const { t } = useI18n()
 | 
				
			||||||
@@ -185,18 +200,28 @@ const props = defineProps({
 | 
				
			|||||||
const submitLabel = computed(() => {
 | 
					const submitLabel = computed(() => {
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    props.submitLabel ||
 | 
					    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({
 | 
					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({
 | 
					const actionConfig = ref({
 | 
				
			||||||
  actions: macroActions,
 | 
					  actions: macroActions,
 | 
				
			||||||
  typePlaceholder: t('form.field.selectActionType'),
 | 
					  typePlaceholder: t('globals.messages.select', { name: t('globals.terms.action').toLowerCase() }),
 | 
				
			||||||
  valuePlaceholder: t('form.field.selectValue'),
 | 
					  valuePlaceholder: t('globals.messages.select', { name: t('globals.terms.value').toLowerCase() }),
 | 
				
			||||||
  addButtonText: t('form.field.addNewAction')
 | 
					  addButtonText: t('globals.messages.new', {
 | 
				
			||||||
 | 
					    name: t('globals.terms.action').toLowerCase()
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const onSubmit = form.handleSubmit(async (values) => {
 | 
					const onSubmit = form.handleSubmit(async (values) => {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -6,16 +6,16 @@ export const createColumns = (t) => [
 | 
				
			|||||||
  {
 | 
					  {
 | 
				
			||||||
    accessorKey: 'name',
 | 
					    accessorKey: 'name',
 | 
				
			||||||
    header: function () {
 | 
					    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 }) {
 | 
					    cell: function ({ row }) {
 | 
				
			||||||
      return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
 | 
					      return h('div', { class: 'text-center' }, row.getValue('name'))
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  {
 | 
					  {
 | 
				
			||||||
    accessorKey: 'visibility',
 | 
					    accessorKey: 'visibility',
 | 
				
			||||||
    header: function () {
 | 
					    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 }) {
 | 
					    cell: function ({ row }) {
 | 
				
			||||||
      return h('div', { class: 'text-center' }, row.getValue('visibility'))
 | 
					      return h('div', { class: 'text-center' }, row.getValue('visibility'))
 | 
				
			||||||
@@ -24,7 +24,7 @@ export const createColumns = (t) => [
 | 
				
			|||||||
  {
 | 
					  {
 | 
				
			||||||
    accessorKey: 'usage_count',
 | 
					    accessorKey: 'usage_count',
 | 
				
			||||||
    header: function () {
 | 
					    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 }) {
 | 
					    cell: function ({ row }) {
 | 
				
			||||||
      return h('div', { class: 'text-center' }, row.getValue('usage_count'))
 | 
					      return h('div', { class: 'text-center' }, row.getValue('usage_count'))
 | 
				
			||||||
@@ -33,7 +33,7 @@ export const createColumns = (t) => [
 | 
				
			|||||||
  {
 | 
					  {
 | 
				
			||||||
    accessorKey: 'created_at',
 | 
					    accessorKey: 'created_at',
 | 
				
			||||||
    header: function () {
 | 
					    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 }) {
 | 
					    cell: function ({ row }) {
 | 
				
			||||||
      return h('div', { class: 'text-center' }, format(row.getValue('created_at'), 'PPpp'))
 | 
					      return h('div', { class: 'text-center' }, format(row.getValue('created_at'), 'PPpp'))
 | 
				
			||||||
@@ -42,7 +42,7 @@ export const createColumns = (t) => [
 | 
				
			|||||||
  {
 | 
					  {
 | 
				
			||||||
    accessorKey: 'updated_at',
 | 
					    accessorKey: 'updated_at',
 | 
				
			||||||
    header: function () {
 | 
					    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 }) {
 | 
					    cell: function ({ row }) {
 | 
				
			||||||
      return h('div', { class: 'text-center' }, format(row.getValue('updated_at'), 'PPpp'))
 | 
					      return h('div', { class: 'text-center' }, format(row.getValue('updated_at'), 'PPpp'))
 | 
				
			||||||
 
 | 
				
			|||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user