mirror of
				https://github.com/abhinavxd/libredesk.git
				synced 2025-10-30 19:43:35 +00:00 
			
		
		
		
	Compare commits
	
		
			72 Commits
		
	
	
		
			v0.1.0-alp
			...
			v0.4.3-alp
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 36d91de8f7 | ||
|  | 57c1948379 | ||
|  | 772152c40c | ||
|  | 8e15d733ea | ||
|  | fc47e65fcb | ||
|  | 760be37eda | ||
|  | d1f08ce035 | ||
|  | 8551b65a27 | ||
|  | eb499f64d0 | ||
|  | 494bc15b0a | ||
|  | 360557c58f | ||
|  | 8d8f08e1d2 | ||
|  | 10b4f9d08c | ||
|  | 79f74363da | ||
|  | 8f6295542e | ||
|  | 8e286e2273 | ||
|  | 3aad69fc52 | ||
|  | 58825c3de9 | ||
|  | 03c68afc4c | ||
|  | 15b9caaaed | ||
|  | b0d3dcb5dd | ||
|  | 96ef62b509 | ||
|  | 79c3f5a60c | ||
|  | 70bef7b3ab | ||
|  | b1e1dff3eb | ||
|  | 9b34c2737d | ||
|  | 1b63f03bb1 | ||
|  | 26d76c966f | ||
|  | 1ff335f772 | ||
|  | 5836ee8d90 | ||
|  | 98534f3c5a | ||
|  | 59951f0829 | ||
|  | 461ae3cf22 | ||
|  | da5dfdbcde | ||
|  | 9c67c02b08 | ||
|  | 15b200b0db | ||
|  | f4617c599c | ||
|  | 341d0b7e47 | ||
|  | 78b8c508d8 | ||
|  | f17d96f96f | ||
|  | c75c117a4d | ||
|  | 873d26ccb2 | ||
|  | 71601364ae | ||
|  | 44723fb70d | ||
|  | 67e1230485 | ||
|  | d58898c60f | ||
|  | a8dc0a6242 | ||
|  | 3aa144f703 | ||
|  | fcbd16f042 | ||
|  | e8f3f24422 | ||
|  | 425bb4ed04 | ||
|  | 0c3da82250 | ||
|  | 8649826a89 | ||
|  | d427dfd20c | ||
|  | afb54c371b | ||
|  | 46459599c7 | ||
|  | 63a6aedfd0 | ||
|  | ffbf613e68 | ||
|  | 88f82fe80b | ||
|  | 914b6371b6 | ||
|  | 89eb05f337 | ||
|  | 71a3588855 | ||
|  | c6baf3f9bf | ||
|  | 368ec3c82b | ||
|  | 4cc40ec5d5 | ||
|  | 171e404e6f | ||
|  | 28f4fda274 | ||
|  | 00ded9c19b | ||
|  | 17efaf0f2c | ||
|  | b44290a6f0 | ||
|  | 1a7ee4d8c6 | ||
|  | ab56d01e22 | 
							
								
								
									
										1
									
								
								.gitattributes
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								.gitattributes
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| VERSION export-subst | ||||
							
								
								
									
										31
									
								
								.github/workflows/github-pages.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								.github/workflows/github-pages.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,31 @@ | ||||
| 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 | ||||
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -7,3 +7,4 @@ libredesk.exe | ||||
| uploads | ||||
| .env | ||||
| dist/ | ||||
| .vscode/ | ||||
|   | ||||
| @@ -10,7 +10,7 @@ before: | ||||
|     - make frontend-build | ||||
|  | ||||
| builds: | ||||
|   - id: "standard" | ||||
|   - id: "universal" | ||||
|     main: ./cmd | ||||
|     env: | ||||
|       - CGO_ENABLED=0 | ||||
| @@ -24,29 +24,13 @@ builds: | ||||
|     goarch: | ||||
|       - amd64 | ||||
|       - arm64 | ||||
|     binary: 'libredesk{{ if eq .Os "windows" }}.exe{{ end }}' | ||||
|     ldflags: | ||||
|       - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} | ||||
|     hooks: | ||||
|       post: make stuff BIN={{ .Path }} | ||||
|  | ||||
|   - id: "arm" | ||||
|     main: ./cmd | ||||
|     env: | ||||
|       - CGO_ENABLED=0 | ||||
|     goos: | ||||
|       - freebsd | ||||
|       - linux | ||||
|       - netbsd | ||||
|       - openbsd | ||||
|     goarch: | ||||
|       - arm | ||||
|     goarm: | ||||
|       - 6 | ||||
|       - 7 | ||||
|     binary: 'libredesk{{ if eq .Os "windows" }}.exe{{ end }}' | ||||
|     ldflags: | ||||
|       - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} | ||||
|       - -s -w -X "main.buildString={{ .Tag }} ({{ .ShortCommit }} {{ .Date }}, {{ .Os }}/{{ .Arch }})" -X "main.versionString={{ .Tag }}" | ||||
|     hooks: | ||||
|       post: make stuff BIN={{ .Path }} | ||||
|  | ||||
| @@ -70,7 +54,7 @@ dockers: | ||||
|     goos: linux | ||||
|     goarch: amd64 | ||||
|     ids: | ||||
|       - standard | ||||
|       - universal | ||||
|     image_templates: | ||||
|       - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-amd64" | ||||
|       - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-amd64" | ||||
| @@ -94,7 +78,7 @@ dockers: | ||||
|     goos: linux | ||||
|     goarch: arm64 | ||||
|     ids: | ||||
|       - standard | ||||
|       - universal | ||||
|     image_templates: | ||||
|       - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-arm64" | ||||
|       - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-arm64" | ||||
| @@ -119,7 +103,7 @@ dockers: | ||||
|     goarch: arm | ||||
|     goarm: 6 | ||||
|     ids: | ||||
|       - arm | ||||
|       - universal | ||||
|     image_templates: | ||||
|       - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-armv6" | ||||
|       - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv6" | ||||
| @@ -144,7 +128,7 @@ dockers: | ||||
|     goarch: arm | ||||
|     goarm: 7 | ||||
|     ids: | ||||
|       - arm | ||||
|       - universal | ||||
|     image_templates: | ||||
|       - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-armv7" | ||||
|       - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv7" | ||||
|   | ||||
							
								
								
									
										26
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										26
									
								
								Makefile
									
									
									
									
									
								
							| @@ -1,8 +1,10 @@ | ||||
| # Build variables | ||||
| LAST_COMMIT := $(shell git rev-parse --short HEAD) | ||||
| LAST_COMMIT_DATE := $(shell git show -s --format=%ci ${LAST_COMMIT}) | ||||
| VERSION := $(shell git describe --tags)  | ||||
| BUILDSTR := ${VERSION} (Commit: ${LAST_COMMIT_DATE} (${LAST_COMMIT}), Build: $(shell date +"%Y-%m-%d %H:%M:%S %z")) | ||||
| # Try to get the commit hash from 1) git 2) the VERSION file 3) fallback. | ||||
| LAST_COMMIT := $(or $(shell git rev-parse --short HEAD 2> /dev/null),$(shell head -n 1 VERSION | grep -oP -m 1 "^[a-z0-9]+$$"), "") | ||||
|  | ||||
| # Try to get the semver from 1) git 2) the VERSION file 3) fallback. | ||||
| VERSION := $(or $(LIBREDESK_VERSION),$(shell git describe --tags --abbrev=0 2> /dev/null),$(shell grep -oP 'tag: \Kv\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?' VERSION),"v0.0.0") | ||||
|  | ||||
| BUILDSTR := ${VERSION} (\#${LAST_COMMIT} $(shell date -u +"%Y-%m-%dT%H:%M:%S%z")) | ||||
|  | ||||
| # Binary names and paths | ||||
| BIN := libredesk | ||||
| @@ -30,13 +32,13 @@ install-deps: $(STUFFBIN) | ||||
| .PHONY: frontend-build | ||||
| frontend-build: install-deps | ||||
| 	@echo "→ Building frontend for production..." | ||||
| 	@cd ${FRONTEND_DIR} && pnpm build | ||||
| 	@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm build | ||||
|  | ||||
| # Run the Go backend server in development mode. | ||||
| .PHONY: run-backend | ||||
| run-backend: | ||||
| 	@echo "→ Running backend..." | ||||
| 	@go run cmd/*.go | ||||
| 	CGO_ENABLED=0 go run -ldflags="-s -w -X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}' -X 'main.frontendDir=frontend/dist'" cmd/*.go | ||||
|  | ||||
| # Run the JS frontend server in development mode. | ||||
| .PHONY: run-frontend | ||||
| @@ -44,19 +46,19 @@ run-frontend: | ||||
| 	@echo "→ Installing frontend dependencies (if not already installed)..." | ||||
| 	@cd ${FRONTEND_DIR} && pnpm install | ||||
| 	@echo "→ Running frontend..." | ||||
| 	@export VUE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm dev | ||||
| 	@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm dev | ||||
|  | ||||
| # Build the backend binary. | ||||
| .PHONY: backend-build | ||||
| backend-build: $(STUFFBIN) | ||||
| .PHONY: build-backend | ||||
| build-backend: $(STUFFBIN) | ||||
| 	@echo "→ Building backend..." | ||||
| 	@CGO_ENABLED=0 go build -a\ | ||||
| 		-ldflags="-X 'main.buildString=${BUILDSTR}' -X 'main.buildDate=${LAST_COMMIT_DATE}' -s -w" \ | ||||
| 		-ldflags="-X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}' -s -w" \ | ||||
| 		-o ${BIN} cmd/*.go | ||||
|  | ||||
| # Main build target: builds both frontend and backend, then stuffs static assets into the binary. | ||||
| .PHONY: build | ||||
| build: frontend-build backend-build stuff | ||||
| build: frontend-build build-backend stuff | ||||
| 	@echo "→ Build successful. Current version: $(VERSION)" | ||||
|  | ||||
| # Stuff static assets into the binary using stuffbin. | ||||
|   | ||||
							
								
								
									
										84
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										84
									
								
								README.md
									
									
									
									
									
								
							| @@ -1,4 +1,4 @@ | ||||
| <a href="https://zerodha.tech"><img src="https://zerodha.tech/static/images/github-badge.svg" align="right" /></a> | ||||
| <a href="https://zerodha.tech"><img src="https://zerodha.tech/static/images/github-badge.svg" align="right" alt="Zerodha Tech Badge" /></a> | ||||
|  | ||||
|  | ||||
| # Libredesk | ||||
| @@ -7,40 +7,76 @@ 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/). | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
| > [!CAUTION] | ||||
| > This project is currently in **alpha**. Features and APIs may change and are not yet fully tested. | ||||
| > **CAUTION:** This project is currently in **alpha**. Features and APIs may change and are not yet fully tested. | ||||
|  | ||||
| ## Features | ||||
|  | ||||
| - **Multi Inbox**   | ||||
|   Libredesk supports multiple inboxes, letting you manage conversations across teams effortlessly. | ||||
| - **Granular Permissions**   | ||||
|   Create custom roles with granular permissions for teams and individual agents. | ||||
| - **Smart Automation**   | ||||
|   Eliminate repetitive tasks with powerful automation rules. Auto-tag, assign, and route conversations based on custom conditions. | ||||
| - **CSAT Surveys**   | ||||
|   Measure customer satisfaction with automated surveys. | ||||
| - **Macros**   | ||||
|   Save frequently sent messages as templates. With one click, send saved responses, set tags, and more. | ||||
| - **Smart Organization**   | ||||
|   Keep conversations organized with tags, custom statuses for conversations, and snoozing. Find any conversation instantly from the search bar. | ||||
| - **Auto Assignment**   | ||||
|   Distribute workload with auto assignment rules. Auto-assign conversations based on agent capacity or custom criteria. | ||||
| - **SLA Management**   | ||||
|   Set and track response time targets. Get notified when conversations are at risk of breaching SLA commitments. | ||||
| - **Business Intelligence**   | ||||
|   Connect your favorite BI tools like Metabase and create custom dashboards and reports with your support data—without lock-ins. | ||||
| - **AI-Assisted Response Rewrite**   | ||||
|   Instantly rewrite responses with AI to make them more friendly, professional, or polished. | ||||
| - **Command Bar**   | ||||
|   Opens with a simple shortcut (CTRL+k) and lets you quickly perform actions on conversations. | ||||
|  | ||||
| And more checkout - [libredesk.io](https://libredesk.io) | ||||
|  | ||||
|  | ||||
| ## Developer Setup | ||||
| ## Installation | ||||
|  | ||||
| #### Prerequisites | ||||
| ### Docker | ||||
|  | ||||
| - **go** | ||||
| - **pnpm** | ||||
| - **postgreSQL >= 13** | ||||
| - **redis** | ||||
| The latest image is available on DockerHub at [`libredesk/libredesk:latest`](https://hub.docker.com/r/libredesk/libredesk/tags?page=1&ordering=last_updated&name=latest) | ||||
|  | ||||
| 1. **Clone the repository**: | ||||
| ```shell | ||||
| # Download the compose file and 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 | ||||
|  | ||||
|    ```bash | ||||
|    git clone https://github.com/abhinavxd/libredesk.git | ||||
|    cd libredesk | ||||
|    ``` | ||||
| # Copy the config.sample.toml to config.toml and edit it as needed. | ||||
| cp config.sample.toml config.toml | ||||
|  | ||||
| 2. **Create config file**: | ||||
| # Run the services in the background. | ||||
| docker compose up -d | ||||
|  | ||||
|    - Copy the sample configuration file `config.toml.sample` to `config.toml`: | ||||
| # Setting System user password. | ||||
| docker exec -it libredesk_app ./libredesk --set-system-user-password | ||||
| ``` | ||||
|  | ||||
|        ```bash | ||||
|        cp config.toml.sample config.toml | ||||
|        ``` | ||||
|    - Edit the `config.toml` file to configure your postgres and redis connection settings. | ||||
| Go to `http://localhost:9000` and login with username `System` and the password you set using the --set-system-user-password command. | ||||
|  | ||||
| 3. **Run in development mode**: | ||||
| See [installation docs](https://libredesk.io/docs/installation/) | ||||
|  | ||||
|    - Backend: `make run-backend` | ||||
|    - Frontend: `make run-frontend` | ||||
| __________________ | ||||
|  | ||||
| ### Binary | ||||
| - Download the [latest release](https://github.com/abhinavxd/libredesk/releases) and extract the libredesk binary. | ||||
| - Copy config.sample.toml to config.toml and edit as needed. | ||||
| - `./libredesk --install` to setup the Postgres DB (or `--upgrade` to upgrade an existing DB. Upgrades are idempotent and running them multiple times have no side effects). | ||||
| - Run `./libredesk --set-system-user-password` to set the password for the System user. | ||||
| - Run `./libredesk` and visit `http://localhost:9000` and login with username `System` and the password you set using the --set-system-user-password command. | ||||
|  | ||||
| See [installation docs](https://libredesk.io/docs/installation) | ||||
| __________________ | ||||
|  | ||||
|  | ||||
| ## 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. | ||||
|   | ||||
							
								
								
									
										25
									
								
								cmd/ai.go
									
									
									
									
									
								
							
							
						
						
									
										25
									
								
								cmd/ai.go
									
									
									
									
									
								
							| @@ -1,6 +1,14 @@ | ||||
| package main | ||||
|  | ||||
| import "github.com/zerodha/fastglue" | ||||
| import ( | ||||
| 	"github.com/abhinavxd/libredesk/internal/envelope" | ||||
| 	"github.com/zerodha/fastglue" | ||||
| ) | ||||
|  | ||||
| type providerUpdateReq struct { | ||||
| 	Provider string `json:"provider"` | ||||
| 	APIKey   string `json:"api_key"` | ||||
| } | ||||
|  | ||||
| // handleAICompletion handles AI completion requests | ||||
| func handleAICompletion(r *fastglue.Request) error { | ||||
| @@ -27,3 +35,18 @@ func handleGetAIPrompts(r *fastglue.Request) error { | ||||
| 	} | ||||
| 	return r.SendEnvelope(resp) | ||||
| } | ||||
|  | ||||
| // handleUpdateAIProvider updates the AI provider | ||||
| func handleUpdateAIProvider(r *fastglue.Request) error { | ||||
| 	var ( | ||||
| 		app = r.Context.(*App) | ||||
| 		req providerUpdateReq | ||||
| 	) | ||||
| 	if err := r.Decode(&req, "json"); err != nil { | ||||
| 		return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, "Error unmarshalling request", nil)) | ||||
| 	} | ||||
| 	if err := app.ai.UpdateProvider(req.Provider, req.APIKey); err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
| 	return r.SendEnvelope("Provider updated successfully") | ||||
| } | ||||
|   | ||||
| @@ -75,7 +75,7 @@ func handleOIDCCallback(r *fastglue.Request) error { | ||||
| 	} | ||||
|  | ||||
| 	// Lookup the user by email and set the session. | ||||
| 	user, err := app.user.GetByEmail(claims.Email) | ||||
| 	user, err := app.user.GetAgentByEmail(claims.Email) | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
|   | ||||
| @@ -3,6 +3,7 @@ package main | ||||
| import ( | ||||
| 	"encoding/json" | ||||
| 	"strconv" | ||||
| 	"strings" | ||||
| 	"time" | ||||
|  | ||||
| 	amodels "github.com/abhinavxd/libredesk/internal/auth/models" | ||||
| @@ -42,7 +43,6 @@ func handleGetAllConversations(r *fastglue.Request) error { | ||||
| 		if conversations[i].SLAPolicyID.Int != 0 { | ||||
| 			setSLADeadlines(app, &conversations[i]) | ||||
| 		} | ||||
| 		conversations[i].ID = 0 | ||||
| 	} | ||||
|  | ||||
| 	return r.SendEnvelope(envelope.PageResults{ | ||||
| @@ -79,7 +79,6 @@ func handleGetAssignedConversations(r *fastglue.Request) error { | ||||
| 		if conversations[i].SLAPolicyID.Int != 0 { | ||||
| 			setSLADeadlines(app, &conversations[i]) | ||||
| 		} | ||||
| 		conversations[i].ID = 0 | ||||
| 	} | ||||
|  | ||||
| 	return r.SendEnvelope(envelope.PageResults{ | ||||
| @@ -116,7 +115,6 @@ func handleGetUnassignedConversations(r *fastglue.Request) error { | ||||
| 		if conversations[i].SLAPolicyID.Int != 0 { | ||||
| 			setSLADeadlines(app, &conversations[i]) | ||||
| 		} | ||||
| 		conversations[i].ID = 0 | ||||
| 	} | ||||
|  | ||||
| 	return r.SendEnvelope(envelope.PageResults{ | ||||
| @@ -153,7 +151,7 @@ func handleGetViewConversations(r *fastglue.Request) error { | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusForbidden, "You don't have access to this view.", nil, envelope.PermissionError) | ||||
| 	} | ||||
|  | ||||
| 	user, err := app.user.Get(auser.ID) | ||||
| 	user, err := app.user.GetAgent(auser.ID) | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
| @@ -195,7 +193,6 @@ func handleGetViewConversations(r *fastglue.Request) error { | ||||
| 		if conversations[i].SLAPolicyID.Int != 0 { | ||||
| 			setSLADeadlines(app, &conversations[i]) | ||||
| 		} | ||||
| 		conversations[i].ID = 0 | ||||
| 	} | ||||
|  | ||||
| 	return r.SendEnvelope(envelope.PageResults{ | ||||
| @@ -248,7 +245,6 @@ func handleGetTeamUnassignedConversations(r *fastglue.Request) error { | ||||
| 		if conversations[i].SLAPolicyID.Int != 0 { | ||||
| 			setSLADeadlines(app, &conversations[i]) | ||||
| 		} | ||||
| 		conversations[i].ID = 0 | ||||
| 	} | ||||
|  | ||||
| 	return r.SendEnvelope(envelope.PageResults{ | ||||
| @@ -268,7 +264,7 @@ func handleGetConversation(r *fastglue.Request) error { | ||||
| 		auser = r.RequestCtx.UserValue("user").(amodels.User) | ||||
| 	) | ||||
|  | ||||
| 	user, err := app.user.Get(auser.ID) | ||||
| 	user, err := app.user.GetAgent(auser.ID) | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
| @@ -284,7 +280,6 @@ func handleGetConversation(r *fastglue.Request) error { | ||||
|  | ||||
| 	prev, _ := app.conversation.GetContactConversations(conv.ContactID) | ||||
| 	conv.PreviousConversations = filterCurrentConv(prev, conv.UUID) | ||||
| 	conv.ID = 0 | ||||
| 	return r.SendEnvelope(conv) | ||||
| } | ||||
|  | ||||
| @@ -295,7 +290,7 @@ func handleUpdateConversationAssigneeLastSeen(r *fastglue.Request) error { | ||||
| 		uuid  = r.RequestCtx.UserValue("uuid").(string) | ||||
| 		auser = r.RequestCtx.UserValue("user").(amodels.User) | ||||
| 	) | ||||
| 	user, err := app.user.Get(auser.ID) | ||||
| 	user, err := app.user.GetAgent(auser.ID) | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
| @@ -316,7 +311,7 @@ func handleGetConversationParticipants(r *fastglue.Request) error { | ||||
| 		uuid  = r.RequestCtx.UserValue("uuid").(string) | ||||
| 		auser = r.RequestCtx.UserValue("user").(amodels.User) | ||||
| 	) | ||||
| 	user, err := app.user.Get(auser.ID) | ||||
| 	user, err := app.user.GetAgent(auser.ID) | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
| @@ -343,7 +338,7 @@ func handleUpdateUserAssignee(r *fastglue.Request) error { | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `assignee_id`", nil, envelope.InputError) | ||||
| 	} | ||||
|  | ||||
| 	user, err := app.user.Get(auser.ID) | ||||
| 	user, err := app.user.GetAgent(auser.ID) | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
| @@ -375,7 +370,7 @@ func handleUpdateTeamAssignee(r *fastglue.Request) error { | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid assignee `id`.", nil, envelope.InputError) | ||||
| 	} | ||||
|  | ||||
| 	user, err := app.user.Get(auser.ID) | ||||
| 	user, err := app.user.GetAgent(auser.ID) | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
| @@ -426,7 +421,7 @@ func handleUpdateConversationPriority(r *fastglue.Request) error { | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
| 	user, err := app.user.Get(auser.ID) | ||||
| 	user, err := app.user.GetAgent(auser.ID) | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
| @@ -471,7 +466,7 @@ func handleUpdateConversationStatus(r *fastglue.Request) error { | ||||
| 	} | ||||
|  | ||||
| 	// Enforce conversation access. | ||||
| 	user, err := app.user.Get(auser.ID) | ||||
| 	user, err := app.user.GetAgent(auser.ID) | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
| @@ -528,7 +523,7 @@ func handleUpdateConversationtags(r *fastglue.Request) error { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
|  | ||||
| 	user, err := app.user.Get(auser.ID) | ||||
| 	user, err := app.user.GetAgent(auser.ID) | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
| @@ -607,7 +602,7 @@ func handleRemoveUserAssignee(r *fastglue.Request) error { | ||||
| 		uuid  = r.RequestCtx.UserValue("uuid").(string) | ||||
| 		auser = r.RequestCtx.UserValue("user").(amodels.User) | ||||
| 	) | ||||
| 	user, err := app.user.Get(auser.ID) | ||||
| 	user, err := app.user.GetAgent(auser.ID) | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
| @@ -628,7 +623,7 @@ func handleRemoveTeamAssignee(r *fastglue.Request) error { | ||||
| 		uuid  = r.RequestCtx.UserValue("uuid").(string) | ||||
| 		auser = r.RequestCtx.UserValue("user").(amodels.User) | ||||
| 	) | ||||
| 	user, err := app.user.Get(auser.ID) | ||||
| 	user, err := app.user.GetAgent(auser.ID) | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
| @@ -651,3 +646,99 @@ func filterCurrentConv(convs []cmodels.Conversation, uuid string) []cmodels.Conv | ||||
| 	} | ||||
| 	return []cmodels.Conversation{} | ||||
| } | ||||
|  | ||||
| // handleCreateConversation creates a new conversation and sends a message to it. | ||||
| func handleCreateConversation(r *fastglue.Request) error { | ||||
| 	var ( | ||||
| 		app             = r.Context.(*App) | ||||
| 		auser           = r.RequestCtx.UserValue("user").(amodels.User) | ||||
| 		inboxID         = r.RequestCtx.PostArgs().GetUintOrZero("inbox_id") | ||||
| 		assignedAgentID = r.RequestCtx.PostArgs().GetUintOrZero("agent_id") | ||||
| 		assignedTeamID  = r.RequestCtx.PostArgs().GetUintOrZero("team_id") | ||||
| 		email           = strings.TrimSpace(string(r.RequestCtx.PostArgs().Peek("contact_email"))) | ||||
| 		firstName       = strings.TrimSpace(string(r.RequestCtx.PostArgs().Peek("first_name"))) | ||||
| 		lastName        = strings.TrimSpace(string(r.RequestCtx.PostArgs().Peek("last_name"))) | ||||
| 		subject         = strings.TrimSpace(string(r.RequestCtx.PostArgs().Peek("subject"))) | ||||
| 		content         = string(r.RequestCtx.PostArgs().Peek("content")) | ||||
| 	) | ||||
| 	// Validate required fields | ||||
| 	if inboxID <= 0 { | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "inbox_id is required", nil, envelope.InputError) | ||||
| 	} | ||||
| 	if subject == "" { | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "subject is required", nil, envelope.InputError) | ||||
| 	} | ||||
| 	if content == "" { | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "content is required", nil, envelope.InputError) | ||||
| 	} | ||||
| 	if email == "" { | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Contact email is required", nil, envelope.InputError) | ||||
| 	} | ||||
| 	if firstName == "" { | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "First name is required when creating a new contact", nil, envelope.InputError) | ||||
| 	} | ||||
|  | ||||
| 	user, err := app.user.GetAgent(auser.ID) | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
|  | ||||
| 	// Check if inbox exists and is enabled. | ||||
| 	inbox, err := app.inbox.GetDBRecord(inboxID) | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
| 	if !inbox.Enabled { | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "The chosen inbox is disabled", nil, envelope.InputError) | ||||
| 	} | ||||
|  | ||||
| 	// Find or create contact. | ||||
| 	contact := umodels.User{ | ||||
| 		Email:           null.StringFrom(email), | ||||
| 		SourceChannelID: null.StringFrom(email), | ||||
| 		FirstName:       firstName, | ||||
| 		LastName:        lastName, | ||||
| 		InboxID:         inboxID, | ||||
| 	} | ||||
| 	if err := app.user.CreateContact(&contact); err != nil { | ||||
| 		return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, "Error creating contact", nil)) | ||||
| 	} | ||||
|  | ||||
| 	// Create conversation | ||||
| 	conversationID, conversationUUID, err := app.conversation.CreateConversation( | ||||
| 		contact.ID, | ||||
| 		contact.ContactChannelID, | ||||
| 		inboxID, | ||||
| 		"", /** last_message **/ | ||||
| 		time.Now(), | ||||
| 		subject, | ||||
| 		true, /** append reference number to subject **/ | ||||
| 	) | ||||
| 	if err != nil { | ||||
| 		app.lo.Error("error creating conversation", "error", err) | ||||
| 		return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, "Error creating conversation", nil)) | ||||
| 	} | ||||
|  | ||||
| 	// Send reply to the created conversation. | ||||
| 	if err := app.conversation.SendReply(nil /**media**/, inboxID, auser.ID, conversationUUID, content, nil /**cc**/, nil /**bcc**/, map[string]any{} /**meta**/); err != nil { | ||||
| 		if err := app.conversation.DeleteConversation(conversationUUID); err != nil { | ||||
| 			app.lo.Error("error deleting conversation", "error", err) | ||||
| 		} | ||||
| 		return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, "Error sending message", nil)) | ||||
| 	} | ||||
|  | ||||
| 	// Assign the conversation to the agent or team. | ||||
| 	if assignedAgentID > 0 { | ||||
| 		app.conversation.UpdateConversationUserAssignee(conversationUUID, assignedAgentID, user) | ||||
| 	} | ||||
| 	if assignedTeamID > 0 { | ||||
| 		app.conversation.UpdateConversationTeamAssignee(conversationUUID, assignedTeamID, user) | ||||
| 	} | ||||
|  | ||||
| 	// Send the created conversation back to the client. | ||||
| 	conversation, err := app.conversation.GetConversation(conversationID, "") | ||||
| 	if err != nil { | ||||
| 		app.lo.Error("error fetching created conversation", "error", err) | ||||
| 	} | ||||
| 	return r.SendEnvelope(conversation) | ||||
| } | ||||
|   | ||||
| @@ -63,10 +63,12 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) { | ||||
| 	g.GET("/api/v1/conversations/{uuid}/messages", perm(handleGetMessages, "messages:read")) | ||||
| 	g.POST("/api/v1/conversations/{cuuid}/messages", perm(handleSendMessage, "messages:write")) | ||||
| 	g.PUT("/api/v1/conversations/{cuuid}/messages/{uuid}/retry", perm(handleRetryMessage, "messages:write")) | ||||
| 	g.POST("/api/v1/conversations", perm(handleCreateConversation, "conversations:write")) | ||||
|  | ||||
| 	// Search. | ||||
| 	g.GET("/api/v1/conversations/search", perm(handleSearchConversations, "conversations:read")) | ||||
| 	g.GET("/api/v1/messages/search", perm(handleSearchMessages, "messages:read")) | ||||
| 	g.GET("/api/v1/contacts/search", perm(handleSearchContacts, "conversations:write")) | ||||
|  | ||||
| 	// Views. | ||||
| 	g.GET("/api/v1/views/me", perm(handleGetUserViews, "view:manage")) | ||||
| @@ -99,6 +101,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) { | ||||
| 	g.GET("/api/v1/users/me", auth(handleGetCurrentUser)) | ||||
| 	g.PUT("/api/v1/users/me", auth(handleUpdateCurrentUser)) | ||||
| 	g.GET("/api/v1/users/me/teams", auth(handleGetCurrentUserTeams)) | ||||
| 	g.PUT("/api/v1/users/me/availability", auth(handleUpdateUserAvailability)) | ||||
| 	g.DELETE("/api/v1/users/me/avatar", auth(handleDeleteAvatar)) | ||||
| 	g.GET("/api/v1/users/compact", auth(handleGetUsersCompact)) | ||||
| 	g.GET("/api/v1/users", perm(handleGetUsers, "users:manage")) | ||||
| @@ -173,6 +176,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) { | ||||
| 	// AI completion. | ||||
| 	g.GET("/api/v1/ai/prompts", auth(handleGetAIPrompts)) | ||||
| 	g.POST("/api/v1/ai/completion", auth(handleAICompletion)) | ||||
| 	g.PUT("/api/v1/ai/provider", perm(handleUpdateAIProvider, "ai:manage")) | ||||
|  | ||||
| 	// WebSocket. | ||||
| 	g.GET("/ws", auth(func(r *fastglue.Request) error { | ||||
|   | ||||
| @@ -308,6 +308,11 @@ func initCSAT(db *sqlx.DB) *csat.Manager { | ||||
| 	return m | ||||
| } | ||||
|  | ||||
| // initWS inits websocket hub. | ||||
| func initWS(user *user.Manager) *ws.Hub { | ||||
| 	return ws.NewHub(user) | ||||
| } | ||||
|  | ||||
| // initTemplates inits template manager. | ||||
| func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts *constants) *tmpl.Manager { | ||||
| 	var ( | ||||
| @@ -549,7 +554,7 @@ func initEmailInbox(inboxRecord imodels.Inbox, store inbox.MessageStore) (inbox. | ||||
| 		return nil, fmt.Errorf("initializing `%s` inbox: `%s` error : %w", inboxRecord.Channel, inboxRecord.Name, err) | ||||
| 	} | ||||
|  | ||||
| 	log.Printf("`%s` inbox successfully initialized. %d SMTP servers. %d IMAP clients.", inboxRecord.Name, len(config.SMTP), len(config.IMAP)) | ||||
| 	log.Printf("`%s` inbox successfully initialized", inboxRecord.Name) | ||||
|  | ||||
| 	return inbox, nil | ||||
| } | ||||
|   | ||||
| @@ -9,10 +9,10 @@ import ( | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/abhinavxd/libredesk/internal/colorlog" | ||||
| 	"github.com/abhinavxd/libredesk/internal/dbutil" | ||||
| 	"github.com/abhinavxd/libredesk/internal/user" | ||||
| 	"github.com/jmoiron/sqlx" | ||||
| 	"github.com/knadh/stuffbin" | ||||
| 	"github.com/lib/pq" | ||||
| ) | ||||
|  | ||||
| // Install checks if the schema is already installed, prompts for confirmation, and installs the schema if needed. | ||||
| @@ -24,7 +24,7 @@ func install(ctx context.Context, db *sqlx.DB, fs stuffbin.FileSystem, idempoten | ||||
| 	} | ||||
|  | ||||
| 	// Make sure the system user password is strong enough. | ||||
| 	password := strings.TrimSpace(os.Getenv("LIBREDESK_SYSTEM_USER_PASSWORD")) | ||||
| 	password := os.Getenv("LIBREDESK_SYSTEM_USER_PASSWORD") | ||||
| 	if password != "" && !user.IsStrongSystemUserPassword(password) && !schemaInstalled { | ||||
| 		log.Fatalf("system user password is not strong, %s", user.SystemUserPasswordHint) | ||||
| 	} | ||||
| @@ -76,7 +76,7 @@ func setSystemUserPass(ctx context.Context, db *sqlx.DB) { | ||||
| // checkSchema verifies if the DB schema is already installed by querying a table. | ||||
| func checkSchema(db *sqlx.DB) (bool, error) { | ||||
| 	if _, err := db.Exec(`SELECT * FROM settings LIMIT 1`); err != nil { | ||||
| 		if pqErr, ok := err.(*pq.Error); ok && pqErr.Code == "42P01" { | ||||
| 		if dbutil.IsTableNotExistError(err) { | ||||
| 			return false, nil | ||||
| 		} | ||||
| 		return false, err | ||||
|   | ||||
							
								
								
									
										17
									
								
								cmd/login.go
									
									
									
									
									
								
							
							
						
						
									
										17
									
								
								cmd/login.go
									
									
									
									
									
								
							| @@ -3,6 +3,7 @@ package main | ||||
| import ( | ||||
| 	amodels "github.com/abhinavxd/libredesk/internal/auth/models" | ||||
| 	"github.com/abhinavxd/libredesk/internal/envelope" | ||||
| 	umodels "github.com/abhinavxd/libredesk/internal/user/models" | ||||
| 	"github.com/valyala/fasthttp" | ||||
| 	"github.com/zerodha/fastglue" | ||||
| ) | ||||
| @@ -11,14 +12,24 @@ import ( | ||||
| func handleLogin(r *fastglue.Request) error { | ||||
| 	var ( | ||||
| 		app      = r.Context.(*App) | ||||
| 		p        = r.RequestCtx.PostArgs() | ||||
| 		email    = string(p.Peek("email")) | ||||
| 		password = p.Peek("password") | ||||
| 		email    = string(r.RequestCtx.PostArgs().Peek("email")) | ||||
| 		password = r.RequestCtx.PostArgs().Peek("password") | ||||
| 	) | ||||
| 	user, err := app.user.VerifyPassword(email, password) | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
|  | ||||
| 	if !user.Enabled { | ||||
| 		return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, "Your account is disabled, please contact administrator", nil)) | ||||
| 	} | ||||
|  | ||||
| 	// Set user availability status to online. | ||||
| 	if err := app.user.UpdateAvailability(user.ID, umodels.Online); err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
| 	user.AvailabilityStatus = umodels.Online | ||||
|  | ||||
| 	if err := app.auth.SaveSession(amodels.User{ | ||||
| 		ID:        user.ID, | ||||
| 		Email:     user.Email.String, | ||||
|   | ||||
| @@ -145,7 +145,7 @@ func handleApplyMacro(r *fastglue.Request) error { | ||||
| 		id, _            = strconv.Atoi(r.RequestCtx.UserValue("id").(string)) | ||||
| 		incomingActions  = []autoModels.RuleAction{} | ||||
| 	) | ||||
| 	user, err := app.user.Get(auser.ID) | ||||
| 	user, err := app.user.GetAgent(auser.ID) | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
| @@ -239,7 +239,7 @@ func setDisplayValues(app *App, actions []autoModels.RuleAction) error { | ||||
| 			return t.Name, nil | ||||
| 		}, | ||||
| 		autoModels.ActionAssignUser: func(id int) (string, error) { | ||||
| 			u, err := app.user.Get(id) | ||||
| 			u, err := app.user.GetAgent(id) | ||||
| 			if err != nil { | ||||
| 				app.lo.Warn("user not found for macro action", "user_id", id) | ||||
| 				return "", err | ||||
|   | ||||
							
								
								
									
										30
									
								
								cmd/main.go
									
									
									
									
									
								
							
							
						
						
									
										30
									
								
								cmd/main.go
									
									
									
									
									
								
							| @@ -6,8 +6,10 @@ import ( | ||||
| 	"log" | ||||
| 	"os" | ||||
| 	"os/signal" | ||||
| 	"sync" | ||||
| 	"sync/atomic" | ||||
| 	"syscall" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/abhinavxd/libredesk/internal/ai" | ||||
| 	auth_ "github.com/abhinavxd/libredesk/internal/auth" | ||||
| @@ -34,7 +36,6 @@ import ( | ||||
| 	"github.com/abhinavxd/libredesk/internal/team" | ||||
| 	"github.com/abhinavxd/libredesk/internal/template" | ||||
| 	"github.com/abhinavxd/libredesk/internal/user" | ||||
| 	"github.com/abhinavxd/libredesk/internal/ws" | ||||
| 	"github.com/knadh/go-i18n" | ||||
| 	"github.com/knadh/koanf/v2" | ||||
| 	"github.com/knadh/stuffbin" | ||||
| @@ -50,7 +51,8 @@ var ( | ||||
| 	frontendDir = "frontend/dist" | ||||
|  | ||||
| 	// Injected at build time. | ||||
| 	buildString = "" | ||||
| 	buildString   string | ||||
| 	versionString string | ||||
| ) | ||||
|  | ||||
| // App is the global app context which is passed and injected in the http handlers. | ||||
| @@ -82,6 +84,10 @@ type App struct { | ||||
| 	ai            *ai.Manager | ||||
| 	search        *search.Manager | ||||
| 	notifier      *notifier.Service | ||||
|  | ||||
| 	// Global state that stores data on an available app update. | ||||
| 	update *AppUpdate | ||||
| 	sync.Mutex | ||||
| } | ||||
|  | ||||
| func main() { | ||||
| @@ -99,9 +105,8 @@ func main() { | ||||
| 	} | ||||
|  | ||||
| 	// Build string injected at build time. | ||||
| 	if buildString != "" { | ||||
| 		colorlog.Green("Build: %s", buildString) | ||||
| 	} | ||||
| 	colorlog.Green("Build: %s", buildString) | ||||
| 	colorlog.Green("Version: %s", versionString) | ||||
|  | ||||
| 	// Load the config files into Koanf. | ||||
| 	initConfig(ko) | ||||
| @@ -136,10 +141,13 @@ func main() { | ||||
|  | ||||
| 	// Upgrade. | ||||
| 	if ko.Bool("upgrade") { | ||||
| 		log.Println("no upgrades available") | ||||
| 		upgrade(db, fs, !ko.Bool("yes")) | ||||
| 		os.Exit(0) | ||||
| 	} | ||||
|  | ||||
| 	// Check for pending upgrade. | ||||
| 	checkPendingUpgrade(db) | ||||
|  | ||||
| 	// Load app settings from DB into the Koanf instance. | ||||
| 	settings := initSettings(db) | ||||
| 	loadSettings(settings) | ||||
| @@ -153,7 +161,6 @@ func main() { | ||||
| 		messageOutgoingScanInterval = ko.MustDuration("message.message_outoing_scan_interval") | ||||
| 		slaEvaluationInterval       = ko.MustDuration("sla.evaluation_interval") | ||||
| 		lo                          = initLogger(appName) | ||||
| 		wsHub                       = ws.NewHub() | ||||
| 		rdb                         = initRedis() | ||||
| 		constants                   = initConstants() | ||||
| 		i18n                        = initI18n(fs) | ||||
| @@ -168,6 +175,7 @@ func main() { | ||||
| 		team                        = initTeam(db) | ||||
| 		businessHours               = initBusinessHours(db) | ||||
| 		user                        = initUser(i18n, db) | ||||
| 		wsHub                       = initWS(user) | ||||
| 		notifier                    = initNotifier(user) | ||||
| 		automation                  = initAutomationEngine(db) | ||||
| 		sla                         = initSLA(db, team, settings, businessHours) | ||||
| @@ -184,6 +192,7 @@ func main() { | ||||
| 	go notifier.Run(ctx) | ||||
| 	go sla.Run(ctx, slaEvaluationInterval) | ||||
| 	go media.DeleteUnlinkedMedia(ctx) | ||||
| 	go user.MonitorAgentAvailability(ctx) | ||||
|  | ||||
| 	var app = &App{ | ||||
| 		lo:            lo, | ||||
| @@ -226,7 +235,7 @@ func main() { | ||||
| 		WriteTimeout:         ko.MustDuration("app.server.write_timeout"), | ||||
| 		MaxRequestBodySize:   ko.MustInt("app.server.max_body_size"), | ||||
| 		MaxKeepaliveDuration: ko.MustDuration("app.server.keepalive_timeout"), | ||||
| 		ReadBufferSize:       ko.MustInt("app.server.max_body_size"), | ||||
| 		ReadBufferSize:       ko.Int("app.server.read_buffer_size"), | ||||
| 	} | ||||
|  | ||||
| 	go func() { | ||||
| @@ -239,6 +248,11 @@ func main() { | ||||
| 		} | ||||
| 	}() | ||||
|  | ||||
| 	// Start the app update checker. | ||||
| 	if ko.Bool("app.check_updates") { | ||||
| 		go checkUpdates(versionString, time.Hour*1, app) | ||||
| 	} | ||||
|  | ||||
| 	// Wait for shutdown signal. | ||||
| 	<-ctx.Done() | ||||
| 	colorlog.Red("Shutting down HTTP server...") | ||||
|   | ||||
| @@ -150,7 +150,7 @@ func handleServeMedia(r *fastglue.Request) error { | ||||
| 		uuid  = r.RequestCtx.UserValue("uuid").(string) | ||||
| 	) | ||||
|  | ||||
| 	user, err := app.user.Get(auser.ID) | ||||
| 	user, err := app.user.GetAgent(auser.ID) | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
|   | ||||
| @@ -30,7 +30,7 @@ func handleGetMessages(r *fastglue.Request) error { | ||||
| 		total       = 0 | ||||
| 	) | ||||
|  | ||||
| 	user, err := app.user.Get(auser.ID) | ||||
| 	user, err := app.user.GetAgent(auser.ID) | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
| @@ -70,7 +70,7 @@ func handleGetMessage(r *fastglue.Request) error { | ||||
| 		cuuid = r.RequestCtx.UserValue("cuuid").(string) | ||||
| 		auser = r.RequestCtx.UserValue("user").(amodels.User) | ||||
| 	) | ||||
| 	user, err := app.user.Get(auser.ID) | ||||
| 	user, err := app.user.GetAgent(auser.ID) | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
| @@ -105,7 +105,7 @@ func handleRetryMessage(r *fastglue.Request) error { | ||||
| 		auser = r.RequestCtx.UserValue("user").(amodels.User) | ||||
| 	) | ||||
|  | ||||
| 	user, err := app.user.Get(auser.ID) | ||||
| 	user, err := app.user.GetAgent(auser.ID) | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
| @@ -133,13 +133,13 @@ func handleSendMessage(r *fastglue.Request) error { | ||||
| 		req   = messageReq{} | ||||
| 	) | ||||
|  | ||||
| 	user, err := app.user.Get(auser.ID) | ||||
| 	user, err := app.user.GetAgent(auser.ID) | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
|  | ||||
| 	// Check permission | ||||
| 	_, err = enforceConversationAccess(app, cuuid, user) | ||||
| 	conv, err := enforceConversationAccess(app, cuuid, user) | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
| @@ -163,7 +163,7 @@ func handleSendMessage(r *fastglue.Request) error { | ||||
| 			return sendErrorEnvelope(r, err) | ||||
| 		} | ||||
| 	} else { | ||||
| 		if err := app.conversation.SendReply(media, user.ID, cuuid, req.Message, req.CC, req.BCC, map[string]interface{}{}); err != nil { | ||||
| 		if err := app.conversation.SendReply(media, conv.InboxID, user.ID, cuuid, req.Message, req.CC, req.BCC, map[string]any{} /**meta**/); err != nil { | ||||
| 			return sendErrorEnvelope(r, err) | ||||
| 		} | ||||
| 		// Evaluate automation rules. | ||||
|   | ||||
| @@ -8,6 +8,7 @@ import ( | ||||
| 	"github.com/abhinavxd/libredesk/internal/envelope" | ||||
| 	"github.com/valyala/fasthttp" | ||||
| 	"github.com/zerodha/fastglue" | ||||
| 	"github.com/zerodha/simplesessions/v3" | ||||
| ) | ||||
|  | ||||
| // tryAuth is a middleware that attempts to authenticate the user and add them to the context | ||||
| @@ -23,7 +24,7 @@ func tryAuth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler { | ||||
| 		} | ||||
|  | ||||
| 		// Try to get user. | ||||
| 		user, err := app.user.Get(userSession.ID) | ||||
| 		user, err := app.user.GetAgent(userSession.ID) | ||||
| 		if err != nil { | ||||
| 			return handler(r) | ||||
| 		} | ||||
| @@ -43,9 +44,7 @@ func tryAuth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler { | ||||
| // auth makes sure the user is logged in. | ||||
| func auth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler { | ||||
| 	return func(r *fastglue.Request) error { | ||||
| 		var ( | ||||
| 			app = r.Context.(*App) | ||||
| 		) | ||||
| 		var app = r.Context.(*App) | ||||
|  | ||||
| 		// Validate session and fetch user. | ||||
| 		userSession, err := app.auth.ValidateSession(r) | ||||
| @@ -55,7 +54,7 @@ func auth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler { | ||||
| 		} | ||||
|  | ||||
| 		// Set user in the request context. | ||||
| 		user, err := app.user.Get(userSession.ID) | ||||
| 		user, err := app.user.GetAgent(userSession.ID) | ||||
| 		if err != nil { | ||||
| 			return sendErrorEnvelope(r, err) | ||||
| 		} | ||||
| @@ -92,11 +91,19 @@ func perm(handler fastglue.FastRequestHandler, perm string) fastglue.FastRequest | ||||
| 		} | ||||
|  | ||||
| 		// Get user from DB. | ||||
| 		user, err := app.user.Get(sessUser.ID) | ||||
| 		user, err := app.user.GetAgent(sessUser.ID) | ||||
| 		if err != nil { | ||||
| 			return sendErrorEnvelope(r, err) | ||||
| 		} | ||||
|  | ||||
| 		// Destroy session if user is disabled. | ||||
| 		if !user.Enabled { | ||||
| 			if err := app.auth.DestroySession(r); err != nil { | ||||
| 				app.lo.Error("error destroying session", "error", err) | ||||
| 			} | ||||
| 			return r.SendErrorEnvelope(http.StatusUnauthorized, "User account disabled", nil, envelope.PermissionError) | ||||
| 		} | ||||
|  | ||||
| 		// Split the permission string into object and action and enforce it. | ||||
| 		parts := strings.Split(perm, ":") | ||||
| 		if len(parts) != 2 { | ||||
| @@ -131,9 +138,17 @@ func authPage(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler { | ||||
| 		// Validate session. | ||||
| 		user, err := app.auth.ValidateSession(r) | ||||
| 		if err != nil { | ||||
| 			app.lo.Error("error validating session", "error", err) | ||||
| 			return r.SendErrorEnvelope(http.StatusUnauthorized, "Invalid or expired session", nil, envelope.PermissionError) | ||||
| 			// Session is not valid, destroy it and redirect to login. | ||||
| 			if err != simplesessions.ErrInvalidSession { | ||||
| 				app.lo.Error("error validating session", "error", err) | ||||
| 				return r.SendErrorEnvelope(http.StatusUnauthorized, "Error validating session", nil, envelope.PermissionError) | ||||
| 			} | ||||
| 			if err := app.auth.DestroySession(r); err != nil { | ||||
| 				app.lo.Error("error destroying session", "error", err) | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		// User is authenticated. | ||||
| 		if user.ID > 0 { | ||||
| 			return handler(r) | ||||
| 		} | ||||
| @@ -142,7 +157,7 @@ func authPage(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler { | ||||
| 		if len(nextURI) == 0 { | ||||
| 			nextURI = r.RequestCtx.RequestURI() | ||||
| 		} | ||||
| 		return r.RedirectURI("/", fasthttp.StatusFound, map[string]interface{}{ | ||||
| 		return r.RedirectURI("/", fasthttp.StatusFound, map[string]any{ | ||||
| 			"next": string(nextURI), | ||||
| 		}, "") | ||||
| 	} | ||||
|   | ||||
| @@ -2,9 +2,11 @@ package main | ||||
|  | ||||
| import ( | ||||
| 	"strconv" | ||||
| 	"strings" | ||||
|  | ||||
| 	"github.com/abhinavxd/libredesk/internal/envelope" | ||||
| 	"github.com/abhinavxd/libredesk/internal/oidc/models" | ||||
| 	"github.com/abhinavxd/libredesk/internal/stringutil" | ||||
| 	"github.com/valyala/fasthttp" | ||||
| 	"github.com/zerodha/fastglue" | ||||
| ) | ||||
| @@ -26,6 +28,10 @@ func handleGetAllOIDC(r *fastglue.Request) error { | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
| 	// Replace secrets with dummy values. | ||||
| 	for i := range out { | ||||
| 		out[i].ClientSecret = strings.Repeat(stringutil.PasswordDummy, 10) | ||||
| 	} | ||||
| 	return r.SendEnvelope(out) | ||||
| } | ||||
|  | ||||
|   | ||||
							
								
								
									
										20
									
								
								cmd/roles.go
									
									
									
									
									
								
							
							
						
						
									
										20
									
								
								cmd/roles.go
									
									
									
									
									
								
							| @@ -9,6 +9,7 @@ import ( | ||||
| 	"github.com/zerodha/fastglue" | ||||
| ) | ||||
|  | ||||
| // handleGetRoles returns all roles | ||||
| func handleGetRoles(r *fastglue.Request) error { | ||||
| 	var ( | ||||
| 		app = r.Context.(*App) | ||||
| @@ -20,6 +21,7 @@ func handleGetRoles(r *fastglue.Request) error { | ||||
| 	return r.SendEnvelope(agents) | ||||
| } | ||||
|  | ||||
| // handleGetRole returns a single role | ||||
| func handleGetRole(r *fastglue.Request) error { | ||||
| 	var ( | ||||
| 		app   = r.Context.(*App) | ||||
| @@ -32,18 +34,19 @@ func handleGetRole(r *fastglue.Request) error { | ||||
| 	return r.SendEnvelope(role) | ||||
| } | ||||
|  | ||||
| // handleDeleteRole deletes a role | ||||
| func handleDeleteRole(r *fastglue.Request) error { | ||||
| 	var ( | ||||
| 		app   = r.Context.(*App) | ||||
| 		id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string)) | ||||
| 	) | ||||
| 	err := app.role.Delete(id) | ||||
| 	if err != nil { | ||||
| 	if err := app.role.Delete(id); err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
| 	return r.SendEnvelope(true) | ||||
| 	return r.SendEnvelope("Role deleted successfully") | ||||
| } | ||||
|  | ||||
| // handleCreateRole creates a new role | ||||
| func handleCreateRole(r *fastglue.Request) error { | ||||
| 	var ( | ||||
| 		app = r.Context.(*App) | ||||
| @@ -52,13 +55,13 @@ func handleCreateRole(r *fastglue.Request) error { | ||||
| 	if err := r.Decode(&req, "json"); err != nil { | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError) | ||||
| 	} | ||||
| 	err := app.role.Create(req) | ||||
| 	if err != nil { | ||||
| 	if err := app.role.Create(req); err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
| 	return r.SendEnvelope(true) | ||||
| 	return r.SendEnvelope("Role created successfully") | ||||
| } | ||||
|  | ||||
| // handleUpdateRole updates a role | ||||
| func handleUpdateRole(r *fastglue.Request) error { | ||||
| 	var ( | ||||
| 		app   = r.Context.(*App) | ||||
| @@ -68,9 +71,8 @@ func handleUpdateRole(r *fastglue.Request) error { | ||||
| 	if err := r.Decode(&req, "json"); err != nil { | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError) | ||||
| 	} | ||||
| 	err := app.role.Update(id, req) | ||||
| 	if err != nil { | ||||
| 	if err := app.role.Update(id, req);err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
| 	return r.SendEnvelope(true) | ||||
| 	return r.SendEnvelope("Role updated successfully") | ||||
| } | ||||
|   | ||||
| @@ -44,3 +44,19 @@ func handleSearchMessages(r *fastglue.Request) error { | ||||
| 	} | ||||
| 	return r.SendEnvelope(messages) | ||||
| } | ||||
|  | ||||
| // handleSearchContacts searches contacts based on the query. | ||||
| func handleSearchContacts(r *fastglue.Request) error { | ||||
| 	var ( | ||||
| 		app = r.Context.(*App) | ||||
| 		q   = string(r.RequestCtx.QueryArgs().Peek("query")) | ||||
| 	) | ||||
| 	if len(q) < minSearchQueryLength { | ||||
| 		return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, "Query length should be at least 3 characters", nil)) | ||||
| 	} | ||||
| 	contacts, err := app.search.Contacts(q) | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
| 	return r.SendEnvelope(contacts) | ||||
| } | ||||
| @@ -2,6 +2,7 @@ package main | ||||
|  | ||||
| import ( | ||||
| 	"encoding/json" | ||||
| 	"net/mail" | ||||
| 	"strings" | ||||
|  | ||||
| 	"github.com/abhinavxd/libredesk/internal/envelope" | ||||
| @@ -20,7 +21,17 @@ func handleGetGeneralSettings(r *fastglue.Request) error { | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
| 	return r.SendEnvelope(out) | ||||
| 	// Unmarshal to set the app.update to the settings, so the frontend can show that an update is available. | ||||
| 	var settings map[string]interface{} | ||||
| 	if err := json.Unmarshal(out, &settings); err != nil { | ||||
| 		app.lo.Error("error unmarshalling settings", "err", err) | ||||
| 		return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, "Error fetching settings", nil)) | ||||
| 	} | ||||
| 	// Set the app.update to the settings, adding `app` prefix to the key to match the settings structure in db. | ||||
| 	settings["app.update"] = app.update | ||||
| 	// Set app version. | ||||
| 	settings["app.version"] = versionString | ||||
| 	return r.SendEnvelope(settings) | ||||
| } | ||||
|  | ||||
| // handleUpdateGeneralSettings updates general settings. | ||||
| @@ -90,6 +101,11 @@ func handleUpdateEmailNotificationSettings(r *fastglue.Request) error { | ||||
| 		return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, "Error updating settings", nil)) | ||||
| 	} | ||||
|  | ||||
| 	// Make sure it's a valid from email address. | ||||
| 	if _, err := mail.ParseAddress(req.EmailAddress); err != nil { | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid from email address format", nil, envelope.InputError) | ||||
| 	} | ||||
|  | ||||
| 	if req.Password == "" { | ||||
| 		req.Password = cur.Password | ||||
| 	} | ||||
| @@ -97,5 +113,7 @@ func handleUpdateEmailNotificationSettings(r *fastglue.Request) error { | ||||
| 	if err := app.setting.Update(req); err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
|  | ||||
| 	// No reload implemented, so user has to restart the app. | ||||
| 	return r.SendEnvelope("Settings updated successfully, Please restart the app for changes to take effect.") | ||||
| } | ||||
|   | ||||
							
								
								
									
										98
									
								
								cmd/updates.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										98
									
								
								cmd/updates.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,98 @@ | ||||
| // Copyright Kailash Nadh (https://github.com/knadh/listmonk) | ||||
| // SPDX-License-Identifier: AGPL-3.0 | ||||
| // Adapted from listmonk for Libredesk. | ||||
|  | ||||
| package main | ||||
|  | ||||
| import ( | ||||
| 	"encoding/json" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"regexp" | ||||
| 	"time" | ||||
|  | ||||
| 	"golang.org/x/mod/semver" | ||||
| ) | ||||
|  | ||||
| const updateCheckURL = "https://updates.libredesk.io/updates.json" | ||||
|  | ||||
| type AppUpdate struct { | ||||
| 	Update struct { | ||||
| 		ReleaseVersion string `json:"release_version"` | ||||
| 		ReleaseDate    string `json:"release_date"` | ||||
| 		URL            string `json:"url"` | ||||
| 		Description    string `json:"description"` | ||||
|  | ||||
| 		// This is computed and set locally based on the local version. | ||||
| 		IsNew bool `json:"is_new"` | ||||
| 	} `json:"update"` | ||||
| 	Messages []struct { | ||||
| 		Date        string `json:"date"` | ||||
| 		Title       string `json:"title"` | ||||
| 		Description string `json:"description"` | ||||
| 		URL         string `json:"url"` | ||||
| 		Priority    string `json:"priority"` | ||||
| 	} `json:"messages"` | ||||
| } | ||||
|  | ||||
| var reSemver = regexp.MustCompile(`-(.*)`) | ||||
|  | ||||
| // checkUpdates is a blocking function that checks for updates to the app | ||||
| // at the given intervals. On detecting a new update (new semver), it | ||||
| // sets the global update status that renders a prompt on the UI. | ||||
| func checkUpdates(curVersion string, interval time.Duration, app *App) { | ||||
| 	// Strip -* suffix. | ||||
| 	curVersion = reSemver.ReplaceAllString(curVersion, "") | ||||
|  | ||||
| 	fnCheck := func() { | ||||
| 		resp, err := http.Get(updateCheckURL) | ||||
| 		if err != nil { | ||||
| 			app.lo.Error("error checking for app updates", "err", err) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		if resp.StatusCode != 200 { | ||||
| 			app.lo.Error("non-ok status code checking for app updates", "status", resp.StatusCode) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		b, err := io.ReadAll(resp.Body) | ||||
| 		if err != nil { | ||||
| 			app.lo.Error("error reading response body", "err", err) | ||||
| 			return | ||||
| 		} | ||||
| 		resp.Body.Close() | ||||
|  | ||||
| 		var out AppUpdate | ||||
| 		if err := json.Unmarshal(b, &out); err != nil { | ||||
| 			app.lo.Error("error unmarshalling response body", "err", err) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		// There is an update. Set it on the global app state. | ||||
| 		if semver.IsValid(out.Update.ReleaseVersion) { | ||||
| 			v := reSemver.ReplaceAllString(out.Update.ReleaseVersion, "") | ||||
| 			if semver.Compare(v, curVersion) > 0 { | ||||
| 				out.Update.IsNew = true | ||||
| 				app.lo.Info("new update available", "version", out.Update.ReleaseVersion) | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		app.Lock() | ||||
| 		app.update = &out | ||||
| 		app.Unlock() | ||||
| 	} | ||||
|  | ||||
| 	// Give a 5 minute buffer after app start in case the admin wants to disable | ||||
| 	// update checks entirely and not make a request to upstream. | ||||
| 	time.Sleep(time.Minute * 5) | ||||
| 	fnCheck() | ||||
|  | ||||
| 	// Thereafter, check every $interval. | ||||
| 	ticker := time.NewTicker(interval) | ||||
| 	defer ticker.Stop() | ||||
|  | ||||
| 	for range ticker.C { | ||||
| 		fnCheck() | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										149
									
								
								cmd/upgrade.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										149
									
								
								cmd/upgrade.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,149 @@ | ||||
| // Copyright Kailash Nadh (https://github.com/knadh/listmonk) | ||||
| // SPDX-License-Identifier: AGPL-3.0 | ||||
| // Adapted from listmonk for Libredesk. | ||||
|  | ||||
| package main | ||||
|  | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"log" | ||||
| 	"strings" | ||||
|  | ||||
| 	"github.com/abhinavxd/libredesk/internal/dbutil" | ||||
| 	"github.com/abhinavxd/libredesk/internal/migrations" | ||||
| 	"github.com/jmoiron/sqlx" | ||||
| 	"github.com/knadh/koanf/v2" | ||||
| 	"github.com/knadh/stuffbin" | ||||
| 	"golang.org/x/mod/semver" | ||||
| ) | ||||
|  | ||||
| // migFunc represents a migration function for a particular version. | ||||
| // fn (generally) executes database migrations and additionally | ||||
| // takes the filesystem and config objects in case there are additional bits | ||||
| // of logic to be performed before executing upgrades. fn is idempotent. | ||||
| type migFunc struct { | ||||
| 	version string | ||||
| 	fn      func(*sqlx.DB, stuffbin.FileSystem, *koanf.Koanf) error | ||||
| } | ||||
|  | ||||
| // migList is the list of available migList ordered by the semver. | ||||
| // Each migration is a Go file in internal/migrations named after the semver. | ||||
| // The functions are named as: v0.7.0 => migrations.V0_7_0() and are idempotent. | ||||
| var migList = []migFunc{ | ||||
| 	{"v0.3.0", migrations.V0_3_0}, | ||||
| 	{"v0.4.0", migrations.V0_4_0}, | ||||
| } | ||||
|  | ||||
| // upgrade upgrades the database to the current version by running SQL migration files | ||||
| // for all version from the last known version to the current one. | ||||
| func upgrade(db *sqlx.DB, fs stuffbin.FileSystem, prompt bool) { | ||||
| 	if prompt { | ||||
| 		var ok string | ||||
| 		fmt.Printf("** IMPORTANT: Take a backup of the database before upgrading.\n") | ||||
| 		fmt.Print("continue (y/n)?  ") | ||||
| 		if _, err := fmt.Scanf("%s", &ok); err != nil { | ||||
| 			log.Fatalf("error reading value from terminal: %v", err) | ||||
| 		} | ||||
| 		if !strings.EqualFold(ok, "y") { | ||||
| 			fmt.Println("upgrade cancelled") | ||||
| 			return | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	_, toRun, err := getPendingMigrations(db) | ||||
| 	if err != nil { | ||||
| 		log.Fatalf("error checking migrations: %v", err) | ||||
| 	} | ||||
|  | ||||
| 	// No migrations to run. | ||||
| 	if len(toRun) == 0 { | ||||
| 		log.Printf("no upgrades to run. Database is up to date.") | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	// Execute migrations in succession. | ||||
| 	for _, m := range toRun { | ||||
| 		log.Printf("running migration %s", m.version) | ||||
| 		if err := m.fn(db, fs, ko); err != nil { | ||||
| 			log.Fatalf("error running migration %s: %v", m.version, err) | ||||
| 		} | ||||
|  | ||||
| 		// Record the migration version in the settings table. There was no | ||||
| 		// settings table until v0.7.0, so ignore the no-table errors. | ||||
| 		if err := recordMigrationVersion(m.version, db); err != nil { | ||||
| 			if dbutil.IsTableNotExistError(err) { | ||||
| 				continue | ||||
| 			} | ||||
| 			log.Fatalf("error recording migration version %s: %v", m.version, err) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	log.Printf("upgrade complete") | ||||
| } | ||||
|  | ||||
| // getPendingMigrations gets the pending migrations by comparing the last | ||||
| // recorded migration in the DB against all migrations listed in `migrations`. | ||||
| func getPendingMigrations(db *sqlx.DB) (string, []migFunc, error) { | ||||
| 	lastVer, err := getLastMigrationVersion(db) | ||||
| 	if err != nil { | ||||
| 		return "", nil, err | ||||
| 	} | ||||
|  | ||||
| 	// Iterate through the migration versions and get everything above the last | ||||
| 	// upgraded semver. | ||||
| 	var toRun []migFunc | ||||
| 	for i, m := range migList { | ||||
| 		if semver.Compare(m.version, lastVer) > 0 { | ||||
| 			toRun = migList[i:] | ||||
| 			break | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return lastVer, toRun, nil | ||||
| } | ||||
|  | ||||
| // getLastMigrationVersion returns the last migration semver recorded in the DB. | ||||
| // If there isn't any, `v0.0.0` is returned. | ||||
| func getLastMigrationVersion(db *sqlx.DB) (string, error) { | ||||
| 	var v string | ||||
| 	if err := db.Get(&v, ` | ||||
| 		SELECT COALESCE( | ||||
| 			(SELECT value->>-1 FROM settings WHERE key='migrations'), | ||||
| 		'v0.0.0')`); err != nil { | ||||
| 		if dbutil.IsTableNotExistError(err) { | ||||
| 			return "v0.0.0", nil | ||||
| 		} | ||||
| 		return v, err | ||||
| 	} | ||||
| 	return v, nil | ||||
| } | ||||
|  | ||||
| // recordMigrationVersion inserts the given version (of DB migration) into the | ||||
| // `migrations` array in the settings table. | ||||
| func recordMigrationVersion(ver string, db *sqlx.DB) error { | ||||
| 	_, err := db.Exec(fmt.Sprintf(`INSERT INTO settings (key, value) | ||||
| 	VALUES('migrations', '["%s"]'::JSONB) | ||||
| 	ON CONFLICT (key) DO UPDATE SET value = settings.value || EXCLUDED.value`, ver)) | ||||
| 	return err | ||||
| } | ||||
|  | ||||
| // checkPendingUpgrade checks if the current database schema matches the expected binary version. | ||||
| func checkPendingUpgrade(db *sqlx.DB) { | ||||
| 	lastVer, toRun, err := getPendingMigrations(db) | ||||
| 	if err != nil { | ||||
| 		log.Fatalf("error checking migrations: %v", err) | ||||
| 	} | ||||
|  | ||||
| 	// No migrations to run. | ||||
| 	if len(toRun) == 0 { | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	var vers []string | ||||
| 	for _, m := range toRun { | ||||
| 		vers = append(vers, m.version) | ||||
| 	} | ||||
|  | ||||
| 	log.Fatalf(`there are %d pending database upgrade(s): %v. The last upgrade was %s. Backup the database and run libredesk --upgrade`, | ||||
| 		len(toRun), vers, lastVer) | ||||
| } | ||||
							
								
								
									
										69
									
								
								cmd/users.go
									
									
									
									
									
								
							
							
						
						
									
										69
									
								
								cmd/users.go
									
									
									
									
									
								
							| @@ -22,7 +22,7 @@ import ( | ||||
| ) | ||||
|  | ||||
| const ( | ||||
| 	maxAvatarSizeMB = 5 | ||||
| 	maxAvatarSizeMB = 20 | ||||
| ) | ||||
|  | ||||
| // handleGetUsers returns all users. | ||||
| @@ -39,9 +39,7 @@ func handleGetUsers(r *fastglue.Request) error { | ||||
|  | ||||
| // handleGetUsersCompact returns all users in a compact format. | ||||
| func handleGetUsersCompact(r *fastglue.Request) error { | ||||
| 	var ( | ||||
| 		app = r.Context.(*App) | ||||
| 	) | ||||
| 	var app = r.Context.(*App) | ||||
| 	agents, err := app.user.GetAllCompact() | ||||
| 	if err != nil { | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, "") | ||||
| @@ -59,20 +57,33 @@ func handleGetUser(r *fastglue.Request) error { | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, | ||||
| 			"Invalid user `id`.", nil, envelope.InputError) | ||||
| 	} | ||||
| 	user, err := app.user.Get(id) | ||||
| 	user, err := app.user.GetAgent(id) | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
| 	return r.SendEnvelope(user) | ||||
| } | ||||
|  | ||||
| // handleUpdateUserAvailability updates the current user availability. | ||||
| func handleUpdateUserAvailability(r *fastglue.Request) error { | ||||
| 	var ( | ||||
| 		app    = r.Context.(*App) | ||||
| 		auser  = r.RequestCtx.UserValue("user").(amodels.User) | ||||
| 		status = string(r.RequestCtx.PostArgs().Peek("status")) | ||||
| 	) | ||||
| 	if err := app.user.UpdateAvailability(auser.ID, status); err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
| 	return r.SendEnvelope("User availability updated successfully.") | ||||
| } | ||||
|  | ||||
| // handleGetCurrentUserTeams returns the teams of a user. | ||||
| func handleGetCurrentUserTeams(r *fastglue.Request) error { | ||||
| 	var ( | ||||
| 		app   = r.Context.(*App) | ||||
| 		auser = r.RequestCtx.UserValue("user").(amodels.User) | ||||
| 	) | ||||
| 	user, err := app.user.Get(auser.ID) | ||||
| 	user, err := app.user.GetAgent(auser.ID) | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
| @@ -90,13 +101,7 @@ func handleUpdateCurrentUser(r *fastglue.Request) error { | ||||
| 		app   = r.Context.(*App) | ||||
| 		auser = r.RequestCtx.UserValue("user").(amodels.User) | ||||
| 	) | ||||
| 	user, err := app.user.Get(auser.ID) | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
|  | ||||
| 	// Get current user. | ||||
| 	currentUser, err := app.user.Get(user.ID) | ||||
| 	user, err := app.user.GetAgent(auser.ID) | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
| @@ -154,8 +159,8 @@ func handleUpdateCurrentUser(r *fastglue.Request) error { | ||||
| 		} | ||||
|  | ||||
| 		// Delete current avatar. | ||||
| 		if currentUser.AvatarURL.Valid { | ||||
| 			fileName := filepath.Base(currentUser.AvatarURL.String) | ||||
| 		if user.AvatarURL.Valid { | ||||
| 			fileName := filepath.Base(user.AvatarURL.String) | ||||
| 			app.media.Delete(fileName) | ||||
| 		} | ||||
|  | ||||
| @@ -212,9 +217,9 @@ func handleCreateUser(r *fastglue.Request) error { | ||||
| 		} | ||||
|  | ||||
| 		// Render template and send email. | ||||
| 		content, err := app.tmpl.RenderTemplate(tmpl.TmplWelcome, map[string]interface{}{ | ||||
| 		content, err := app.tmpl.RenderInMemoryTemplate(tmpl.TmplWelcome, map[string]any{ | ||||
| 			"ResetToken": resetToken, | ||||
| 			"Email":      user.Email, | ||||
| 			"Email":      user.Email.String, | ||||
| 		}) | ||||
| 		if err != nil { | ||||
| 			app.lo.Error("error rendering template", "error", err) | ||||
| @@ -228,7 +233,7 @@ func handleCreateUser(r *fastglue.Request) error { | ||||
| 			Provider: notifier.ProviderEmail, | ||||
| 		}); err != nil { | ||||
| 			app.lo.Error("error sending notification message", "error", err) | ||||
| 			return r.SendEnvelope("User created successfully, but error sending welcome email.") | ||||
| 			return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, "User created successfully, but could not send welcome email.", nil)) | ||||
| 		} | ||||
| 	} | ||||
| 	return r.SendEnvelope("User created successfully.") | ||||
| @@ -305,7 +310,7 @@ func handleGetCurrentUser(r *fastglue.Request) error { | ||||
| 		app   = r.Context.(*App) | ||||
| 		auser = r.RequestCtx.UserValue("user").(amodels.User) | ||||
| 	) | ||||
| 	u, err := app.user.Get(auser.ID) | ||||
| 	u, err := app.user.GetAgent(auser.ID) | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
| @@ -320,14 +325,14 @@ func handleDeleteAvatar(r *fastglue.Request) error { | ||||
| 	) | ||||
|  | ||||
| 	// Get user | ||||
| 	user, err := app.user.Get(auser.ID) | ||||
| 	user, err := app.user.GetAgent(auser.ID) | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
|  | ||||
| 	// Valid str? | ||||
| 	if user.AvatarURL.String == "" { | ||||
| 		return r.SendEnvelope(true) | ||||
| 		return r.SendEnvelope("Avatar deleted successfully.") | ||||
| 	} | ||||
|  | ||||
| 	fileName := filepath.Base(user.AvatarURL.String) | ||||
| @@ -336,8 +341,8 @@ func handleDeleteAvatar(r *fastglue.Request) error { | ||||
| 	if err := app.media.Delete(fileName); err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
| 	err = app.user.UpdateAvatar(user.ID, "") | ||||
| 	if err != nil { | ||||
|  | ||||
| 	if err = app.user.UpdateAvatar(user.ID, ""); err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
| 	return r.SendEnvelope("Avatar deleted successfully.") | ||||
| @@ -352,16 +357,17 @@ func handleResetPassword(r *fastglue.Request) error { | ||||
| 		email     = string(p.Peek("email")) | ||||
| 	) | ||||
| 	if ok && auser.ID > 0 { | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "User is already logged in", nil, envelope.InputError) | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "User is already logged in, Please logout to reset password.", nil, envelope.InputError) | ||||
| 	} | ||||
|  | ||||
| 	if email == "" { | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty `email`", nil, envelope.InputError) | ||||
| 	} | ||||
|  | ||||
| 	user, err := app.user.GetByEmail(email) | ||||
| 	user, err := app.user.GetAgentByEmail(email) | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 		// Send 200 even if user not found, to prevent email enumeration. | ||||
| 		return r.SendEnvelope("Reset password email sent successfully.") | ||||
| 	} | ||||
|  | ||||
| 	token, err := app.user.SetResetPasswordToken(user.ID) | ||||
| @@ -370,10 +376,9 @@ func handleResetPassword(r *fastglue.Request) error { | ||||
| 	} | ||||
|  | ||||
| 	// Send email. | ||||
| 	content, err := app.tmpl.RenderTemplate(tmpl.TmplResetPassword, | ||||
| 		map[string]string{ | ||||
| 			"ResetToken": token, | ||||
| 		}) | ||||
| 	content, err := app.tmpl.RenderInMemoryTemplate(tmpl.TmplResetPassword, map[string]string{ | ||||
| 		"ResetToken": token, | ||||
| 	}) | ||||
| 	if err != nil { | ||||
| 		app.lo.Error("error rendering template", "error", err) | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error rendering template", nil, envelope.GeneralError) | ||||
| @@ -385,8 +390,8 @@ func handleResetPassword(r *fastglue.Request) error { | ||||
| 		Content:  content, | ||||
| 		Provider: notifier.ProviderEmail, | ||||
| 	}); err != nil { | ||||
| 		app.lo.Error("error sending notification message", "error", err) | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error sending notification message", nil, envelope.GeneralError) | ||||
| 		app.lo.Error("error sending password reset email", "error", err) | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error sending password reset email", nil, envelope.GeneralError) | ||||
| 	} | ||||
|  | ||||
| 	return r.SendEnvelope("Reset password email sent successfully.") | ||||
|   | ||||
							
								
								
									
										10
									
								
								cmd/views.go
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								cmd/views.go
									
									
									
									
									
								
							| @@ -16,7 +16,7 @@ func handleGetUserViews(r *fastglue.Request) error { | ||||
| 		app   = r.Context.(*App) | ||||
| 		auser = r.RequestCtx.UserValue("user").(amodels.User) | ||||
| 	) | ||||
| 	user, err := app.user.Get(auser.ID) | ||||
| 	user, err := app.user.GetAgent(auser.ID) | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
| @@ -37,7 +37,7 @@ func handleCreateUserView(r *fastglue.Request) error { | ||||
| 	if err := r.Decode(&view, "json"); err != nil { | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError) | ||||
| 	} | ||||
| 	user, err := app.user.Get(auser.ID) | ||||
| 	user, err := app.user.GetAgent(auser.ID) | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
| @@ -46,7 +46,7 @@ func handleCreateUserView(r *fastglue.Request) error { | ||||
| 	} | ||||
|  | ||||
| 	if string(view.Filters) == "" { | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty view `Filter`", nil, envelope.InputError) | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Please provide at least one filter", nil, envelope.InputError) | ||||
| 	} | ||||
|  | ||||
| 	if err := app.view.Create(view.Name, view.Filters, user.ID); err != nil { | ||||
| @@ -71,7 +71,7 @@ func handleDeleteUserView(r *fastglue.Request) error { | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty view `ID`", nil, envelope.InputError) | ||||
| 	} | ||||
|  | ||||
| 	user, err := app.user.Get(auser.ID) | ||||
| 	user, err := app.user.GetAgent(auser.ID) | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
| @@ -109,7 +109,7 @@ func handleUpdateUserView(r *fastglue.Request) error { | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError) | ||||
| 	} | ||||
|  | ||||
| 	user, err := app.user.Get(auser.ID) | ||||
| 	user, err := app.user.GetAgent(auser.ID) | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
|   | ||||
| @@ -2,6 +2,7 @@ | ||||
| [app] | ||||
| log_level = "debug" | ||||
| env = "dev" | ||||
| check_updates = true | ||||
|  | ||||
| # HTTP server. | ||||
| [app.server] | ||||
| @@ -10,9 +11,10 @@ socket = "" | ||||
| read_timeout = "5s" | ||||
| write_timeout = "5s" | ||||
| max_body_size = 500000000 | ||||
| read_buffer_size = 4096 | ||||
| keepalive_timeout = "10s" | ||||
|  | ||||
| # File upload provider to use. | ||||
| # File upload provider to use, either `fs` or `s3`. | ||||
| [upload] | ||||
| provider = "fs" | ||||
|  | ||||
| @@ -32,11 +34,12 @@ expiry = "6h" | ||||
|  | ||||
| # Postgres. | ||||
| [db] | ||||
| # If using docker compose, use the service name as the host. | ||||
| # If using docker compose, use the service name as the host. e.g. db | ||||
| host = "127.0.0.1" | ||||
| port = 5432 | ||||
| user = "postgres" | ||||
| password = "postgres" | ||||
| # Update the following values with your database credentials. | ||||
| user = "libredesk" | ||||
| password = "libredesk" | ||||
| database = "libredesk" | ||||
| ssl_mode = "disable" | ||||
| max_open = 30 | ||||
| @@ -45,7 +48,7 @@ max_lifetime = "300s" | ||||
|  | ||||
| # Redis. | ||||
| [redis] | ||||
| # If using docker compose, use the service name as the host. | ||||
| # If using docker compose, use the service name as the host. e.g. redis:6379 | ||||
| address = "127.0.0.1:6379" | ||||
| password = "" | ||||
| db = 0 | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| services: | ||||
|   # Libredesk app | ||||
|   app: | ||||
|     image: libredesk:latest | ||||
|     image: libredesk/libredesk:latest | ||||
|     container_name: libredesk_app | ||||
|     restart: unless-stopped | ||||
|     ports: | ||||
|   | ||||
							
								
								
									
										31
									
								
								docs/docs/developer-setup.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								docs/docs/developer-setup.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,31 @@ | ||||
| # 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`. | ||||
							
								
								
									
										13
									
								
								docs/docs/index.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								docs/docs/index.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | ||||
| # 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. | ||||
							
								
								
									
										48
									
								
								docs/docs/installation.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								docs/docs/installation.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,48 @@ | ||||
| # Installation | ||||
|  | ||||
| Libredesk is a single binary application that requires postgres and redis to run. You can install it using the binary or docker. | ||||
|  | ||||
| ## Binary | ||||
|  | ||||
| 1. Download the [latest release](https://github.com/abhinavxd/libredesk/releases) and extract the libredesk binary. | ||||
| 2. `./libredesk --install` to install the tables in the Postgres DB (⩾ 13) and set the System user password. | ||||
| 3. Run `./libredesk` and visit `http://localhost:9000` and login with the email `System` and the password you set during installation. | ||||
|  | ||||
| !!! Tip | ||||
|     To set the System user password during installation, set the environment variables: | ||||
|     `LIBREDESK_SYSTEM_USER_PASSWORD=xxxxxxxxxxx ./libredesk --install` | ||||
|  | ||||
|  | ||||
| ## Docker | ||||
|  | ||||
| The latest image is available on DockerHub at `libredesk/libredesk:latest` | ||||
|  | ||||
| The recommended method is to download the [docker-compose.yml](https://github.com/abhinavxd/libredesk/blob/main/docker-compose.yml) file, customize it for your environment and then to simply run `docker compose up -d`. | ||||
|  | ||||
| ```shell | ||||
| # Download the compose file and the sample config file in the current directory. | ||||
| curl -LO https://github.com/abhinavxd/libredesk/raw/main/docker-compose.yml | ||||
| curl -LO https://github.com/abhinavxd/libredesk/raw/main/config.sample.toml | ||||
|  | ||||
| # Copy the config.sample.toml to config.toml and edit it as needed. | ||||
| cp config.sample.toml config.toml | ||||
|  | ||||
| # Run the services in the background. | ||||
| docker compose up -d | ||||
|  | ||||
| # Setting System user password. | ||||
| docker exec -it libredesk_app ./libredesk --set-system-user-password | ||||
| ``` | ||||
|  | ||||
| Go to `http://localhost:9000` and login with the email `System` and the password you set using the `--set-system-user-password` command. | ||||
|  | ||||
| --- | ||||
|  | ||||
|  | ||||
| ## Compiling from source | ||||
|  | ||||
| To compile the latest unreleased version (`main` branch): | ||||
|  | ||||
| 1. Make sure `go`, `nodejs`, and `pnpm` are installed on your system. | ||||
| 2. `git clone git@github.com:abhinavxd/libredesk.git` | ||||
| 3. `cd libredesk && make`. This will generate the `libredesk` binary. | ||||
							
								
								
									
										18
									
								
								docs/docs/upgrade.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								docs/docs/upgrade.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | ||||
| # Upgrade | ||||
|  | ||||
| !!! 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 | ||||
| ``` | ||||
							
								
								
									
										34
									
								
								docs/mkdocs.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								docs/mkdocs.yml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,34 @@ | ||||
| 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 | ||||
|   - Developer Setup: developer-setup.md | ||||
							
								
								
									
										8
									
								
								frontend/.vscode/extensions.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										8
									
								
								frontend/.vscode/extensions.json
									
									
									
									
										vendored
									
									
								
							| @@ -1,8 +0,0 @@ | ||||
| { | ||||
|   "recommendations": [ | ||||
|     "Vue.volar", | ||||
|     "Vue.vscode-typescript-vue-plugin", | ||||
|     "dbaeumer.vscode-eslint", | ||||
|     "esbenp.prettier-vscode" | ||||
|   ] | ||||
| } | ||||
| @@ -7,7 +7,7 @@ | ||||
|   <link rel="preconnect" href="https://fonts.googleapis.com"> | ||||
|   <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | ||||
|   <link | ||||
|     href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap" | ||||
|     href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&display=swap" | ||||
|     rel="stylesheet"> | ||||
| </head> | ||||
|  | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| { | ||||
|   "name": "libredesk", | ||||
|   "version": "0.0.0", | ||||
|   "version": "0.3.0", | ||||
|   "private": true, | ||||
|   "type": "module", | ||||
|   "scripts": { | ||||
| @@ -18,43 +18,32 @@ | ||||
|     "@formkit/auto-animate": "^0.8.2", | ||||
|     "@internationalized/date": "^3.5.5", | ||||
|     "@radix-icons/vue": "^1.0.0", | ||||
|     "@tailwindcss/typography": "^0.5.10", | ||||
|     "@tailwindcss/typography": "^0.5.16", | ||||
|     "@tanstack/vue-table": "^8.19.2", | ||||
|     "@tiptap/extension-image": "^2.5.9", | ||||
|     "@tiptap/extension-link": "^2.9.1", | ||||
|     "@tiptap/extension-ordered-list": "^2.4.0", | ||||
|     "@tiptap/extension-placeholder": "^2.4.0", | ||||
|     "@tiptap/pm": "^2.4.0", | ||||
|     "@tiptap/starter-kit": "^2.4.0", | ||||
|     "@tiptap/suggestion": "^2.4.0", | ||||
|     "@tiptap/vue-3": "^2.4.0", | ||||
|     "@unovis/ts": "^1.4.4", | ||||
|     "@unovis/vue": "^1.4.4", | ||||
|     "@vee-validate/zod": "^4.13.2", | ||||
|     "@vue/reactivity": "^3.4.15", | ||||
|     "@vue/runtime-core": "^3.4.15", | ||||
|     "@vueup/vue-quill": "^1.2.0", | ||||
|     "@vueuse/core": "^12.4.0", | ||||
|     "add": "^2.0.6", | ||||
|     "axios": "^1.7.9", | ||||
|     "class-variance-authority": "^0.7.0", | ||||
|     "clsx": "^2.1.1", | ||||
|     "codeflask": "^1.4.1", | ||||
|     "date-fns": "^3.6.0", | ||||
|     "install": "^0.13.0", | ||||
|     "lucide-vue-next": "^0.378.0", | ||||
|     "mitt": "^3.0.1", | ||||
|     "npm": "^10.4.0", | ||||
|     "npx": "^10.2.2", | ||||
|     "pinia": "^2.1.7", | ||||
|     "qs": "^6.12.1", | ||||
|     "radix-vue": "latest", | ||||
|     "shadcn-vue": "latest", | ||||
|     "tailwind-merge": "^2.3.0", | ||||
|     "tailwindcss-animate": "^1.0.7", | ||||
|     "textarea": "^0.3.0", | ||||
|     "vee-validate": "^4.13.2", | ||||
|     "vue": "^3.4.37", | ||||
|     "vue-dompurify-html": "^5.2.0", | ||||
|     "vue-i18n": "9", | ||||
|     "vue-letter": "^0.2.0", | ||||
|     "vue-picture-cropper": "^0.7.0", | ||||
| @@ -68,7 +57,7 @@ | ||||
|     "@rushstack/eslint-patch": "^1.3.3", | ||||
|     "@vitejs/plugin-vue": "^5.0.3", | ||||
|     "@vue/eslint-config-prettier": "^8.0.0", | ||||
|     "autoprefixer": "latest", | ||||
|     "autoprefixer": "^10.4.20", | ||||
|     "cypress": "^13.6.3", | ||||
|     "eslint": "^8.49.0", | ||||
|     "eslint-plugin-cypress": "^2.15.1", | ||||
| @@ -78,6 +67,7 @@ | ||||
|     "sass": "^1.70.0", | ||||
|     "start-server-and-test": "^2.0.3", | ||||
|     "tailwindcss": "latest", | ||||
|     "tailwindcss-animate": "^1.0.7", | ||||
|     "vite": "^5.4.9" | ||||
|   }, | ||||
|   "packageManager": "pnpm@9.15.3+sha512.1f79bc245a66eb0b07c5d4d83131240774642caaa86ef7d0434ab47c0d16f66b04e21e0c086eb61e62c77efc4d7f7ec071afad3796af64892fae66509173893a" | ||||
|   | ||||
							
								
								
									
										1863
									
								
								frontend/pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1863
									
								
								frontend/pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -46,9 +46,16 @@ | ||||
|         @create-view="openCreateViewForm = true" | ||||
|         @edit-view="editView" | ||||
|         @delete-view="deleteView" | ||||
|         @create-conversation="() => openCreateConversationDialog = true" | ||||
|       > | ||||
|         <div class="flex flex-col h-screen"> | ||||
|           <!-- Show app update only in admin routes --> | ||||
|           <AppUpdate v-if="route.path.startsWith('/admin')" /> | ||||
|  | ||||
|           <!-- Common header for all pages --> | ||||
|           <PageHeader /> | ||||
|  | ||||
|           <!-- Main content --> | ||||
|           <RouterView class="flex-grow" /> | ||||
|         </div> | ||||
|         <ViewForm v-model:openDialog="openCreateViewForm" v-model:view="view" /> | ||||
| @@ -58,6 +65,9 @@ | ||||
|  | ||||
|   <!-- Command box --> | ||||
|   <Command /> | ||||
|  | ||||
|   <!-- Create conversation dialog --> | ||||
|   <CreateConversation v-model="openCreateConversationDialog" /> | ||||
| </template> | ||||
|  | ||||
| <script setup> | ||||
| @@ -75,12 +85,15 @@ import { useTeamStore } from '@/stores/team' | ||||
| import { useSlaStore } from '@/stores/sla' | ||||
| import { useMacroStore } from '@/stores/macro' | ||||
| import { useTagStore } from '@/stores/tag' | ||||
| import { useIdleDetection } from '@/composables/useIdleDetection' | ||||
| import PageHeader from './components/layout/PageHeader.vue' | ||||
| import ViewForm from '@/features/view/ViewForm.vue' | ||||
| import AppUpdate from '@/components/update/AppUpdate.vue' | ||||
| import api from '@/api' | ||||
| import { toast as sooner } from 'vue-sonner' | ||||
| import Sidebar from '@/components/sidebar/Sidebar.vue' | ||||
| import Command from '@/features/command/CommandBox.vue' | ||||
| import CreateConversation from '@/features/conversation/CreateConversation.vue' | ||||
| import { Inbox, Shield, FileLineChart } from 'lucide-vue-next' | ||||
| import { useRoute } from 'vue-router' | ||||
| import { | ||||
| @@ -109,8 +122,11 @@ const tagStore = useTagStore() | ||||
| const userViews = ref([]) | ||||
| const view = ref({}) | ||||
| const openCreateViewForm = ref(false) | ||||
| const openCreateConversationDialog = ref(false) | ||||
|  | ||||
| initWS() | ||||
| useIdleDetection() | ||||
|  | ||||
| onMounted(() => { | ||||
|   initToaster() | ||||
|   listenViewRefresh() | ||||
| @@ -119,8 +135,10 @@ onMounted(() => { | ||||
|  | ||||
| // initialize data stores | ||||
| const initStores = async () => { | ||||
|   if (!userStore.userID) { | ||||
|     await userStore.getCurrentUser() | ||||
|   } | ||||
|   await Promise.allSettled([ | ||||
|     userStore.getCurrentUser(), | ||||
|     getUserViews(), | ||||
|     conversationStore.fetchStatuses(), | ||||
|     conversationStore.fetchPriorities(), | ||||
|   | ||||
| @@ -1,7 +1,27 @@ | ||||
| <template> | ||||
|     <RouterView /> | ||||
|   <RouterView /> | ||||
| </template> | ||||
|  | ||||
| <script setup> | ||||
| import { onMounted } from 'vue' | ||||
| import { RouterView } from 'vue-router' | ||||
| import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' | ||||
| import { useEmitter } from '@/composables/useEmitter' | ||||
| import { toast as sooner } from 'vue-sonner' | ||||
|  | ||||
| const emitter = useEmitter() | ||||
|  | ||||
| onMounted(() => { | ||||
|   initToaster() | ||||
| }) | ||||
|  | ||||
| const initToaster = () => { | ||||
|   emitter.on(EMITTER_EVENTS.SHOW_TOAST, (message) => { | ||||
|     if (message.variant === 'destructive') { | ||||
|       sooner.error(message.description) | ||||
|     } else { | ||||
|       sooner.success(message.description) | ||||
|     } | ||||
|   }) | ||||
| } | ||||
| </script> | ||||
| @@ -35,6 +35,7 @@ http.interceptors.request.use((request) => { | ||||
|  | ||||
| const searchConversations = (params) => http.get('/api/v1/conversations/search', { params }) | ||||
| const searchMessages = (params) => http.get('/api/v1/messages/search', { params }) | ||||
| const searchContacts = (params) => http.get('/api/v1/contacts/search', { params }) | ||||
| const resetPassword = (data) => http.post('/api/v1/users/reset-password', data) | ||||
| const setPassword = (data) => http.post('/api/v1/users/set-password', data) | ||||
| const deleteUser = (id) => http.delete(`/api/v1/users/${id}`) | ||||
| @@ -169,10 +170,12 @@ const updateCurrentUser = (data) => | ||||
| const deleteUserAvatar = () => http.delete('/api/v1/users/me/avatar') | ||||
| const getCurrentUser = () => http.get('/api/v1/users/me') | ||||
| const getCurrentUserTeams = () => http.get('/api/v1/users/me/teams') | ||||
| const updateCurrentUserAvailability = (data) => http.put('/api/v1/users/me/availability', data) | ||||
| const getTags = () => http.get('/api/v1/tags') | ||||
| const upsertTags = (uuid, data) => http.post(`/api/v1/conversations/${uuid}/tags`, data) | ||||
| const updateAssignee = (uuid, assignee_type, data) => http.put(`/api/v1/conversations/${uuid}/assignee/${assignee_type}`, data) | ||||
| const removeAssignee = (uuid, assignee_type) => http.put(`/api/v1/conversations/${uuid}/assignee/${assignee_type}/remove`) | ||||
| const 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`) | ||||
| @@ -264,6 +267,7 @@ const updateView = (id, data) => | ||||
| const deleteView = (id) => http.delete(`/api/v1/views/me/${id}`) | ||||
| const getAiPrompts = () => http.get('/api/v1/ai/prompts') | ||||
| const aiCompletion = (data) => http.post('/api/v1/ai/completion', data) | ||||
| const updateAIProvider = (data) => http.put('/api/v1/ai/provider', data) | ||||
|  | ||||
| export default { | ||||
|   login, | ||||
| @@ -323,12 +327,15 @@ export default { | ||||
|   uploadMedia, | ||||
|   updateAssigneeLastSeen, | ||||
|   updateUser, | ||||
|   updateCurrentUserAvailability, | ||||
|   updateAutomationRule, | ||||
|   updateAutomationRuleWeights, | ||||
|   updateAutomationRulesExecutionMode, | ||||
|   updateAIProvider, | ||||
|   createAutomationRule, | ||||
|   toggleAutomationRule, | ||||
|   deleteAutomationRule, | ||||
|   createConversation, | ||||
|   sendMessage, | ||||
|   retryMessage, | ||||
|   createUser, | ||||
| @@ -373,5 +380,6 @@ export default { | ||||
|   aiCompletion, | ||||
|   searchConversations, | ||||
|   searchMessages, | ||||
|   searchContacts, | ||||
|   removeAssignee, | ||||
| } | ||||
|   | ||||
| @@ -18,6 +18,49 @@ | ||||
|       overflow-x: auto; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .native-html { | ||||
|     p { | ||||
|       margin-bottom: 0.5rem; | ||||
|     } | ||||
|  | ||||
|     ul { | ||||
|       list-style-type: disc; | ||||
|       margin-left: 1.5rem; | ||||
|       margin-top: 0.5rem; | ||||
|       margin-bottom: 0.5rem; | ||||
|     } | ||||
|  | ||||
|     ol { | ||||
|       list-style-type: decimal; | ||||
|       margin-left: 1.5rem; | ||||
|       margin-top: 0.5rem; | ||||
|       margin-bottom: 0.5rem; | ||||
|     } | ||||
|  | ||||
|     li { | ||||
|       padding-left: 0.25rem; | ||||
|     } | ||||
|  | ||||
|     h1, | ||||
|     h2, | ||||
|     h3, | ||||
|     h4, | ||||
|     h5, | ||||
|     h6 { | ||||
|       font-size: 1.25rem; | ||||
|       font-weight: 700; | ||||
|     } | ||||
|  | ||||
|     a { | ||||
|       color: #0066cc; | ||||
|       cursor: pointer; | ||||
|  | ||||
|       &:hover { | ||||
|         color: #003d7a; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| // Theme. | ||||
| @@ -312,3 +355,7 @@ a[data-active='false']:hover { | ||||
|     opacity: 1; | ||||
|   } | ||||
| } | ||||
|  | ||||
| [data-radix-popper-content-wrapper] { | ||||
|   z-index: 9999 !important; | ||||
| } | ||||
|   | ||||
| @@ -18,6 +18,7 @@ import { | ||||
|   SidebarProvider, | ||||
|   SidebarRail | ||||
| } from '@/components/ui/sidebar' | ||||
| import { useAppSettingsStore } from '@/stores/appSettings' | ||||
| import { | ||||
|   ChevronRight, | ||||
|   EllipsisVertical, | ||||
| @@ -43,8 +44,9 @@ defineProps({ | ||||
|   userViews: { type: Array, default: () => [] } | ||||
| }) | ||||
| const userStore = useUserStore() | ||||
| const settingsStore = useAppSettingsStore() | ||||
| const route = useRoute() | ||||
| const emit = defineEmits(['createView', 'editView', 'deleteView']) | ||||
| const emit = defineEmits(['createView', 'editView', 'deleteView', 'createConversation']) | ||||
|  | ||||
| const openCreateViewDialog = () => { | ||||
|   emit('createView') | ||||
| @@ -70,6 +72,8 @@ const isInboxRoute = (path) => { | ||||
| } | ||||
|  | ||||
| const sidebarOpen = useStorage('mainSidebarOpen', true) | ||||
| const teamInboxOpen = useStorage('teamInboxOpen', true) | ||||
| const viewInboxOpen = useStorage('viewInboxOpen', true) | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
| @@ -122,9 +126,13 @@ const sidebarOpen = useStorage('mainSidebarOpen', true) | ||||
|           <SidebarMenu> | ||||
|             <SidebarMenuItem> | ||||
|               <SidebarMenuButton :isActive="isActiveParent('/admin')" asChild> | ||||
|                 <div> | ||||
|                 <div class="flex items-center justify-between w-full"> | ||||
|                   <span class="font-semibold text-xl">Admin</span> | ||||
|                 </div> | ||||
|                 <!-- App version --> | ||||
|                 <div class="text-xs text-muted-foreground ml-2"> | ||||
|                   ({{ settingsStore.settings['app.version'] }}) | ||||
|                 </div> | ||||
|               </SidebarMenuButton> | ||||
|             </SidebarMenuItem> | ||||
|           </SidebarMenu> | ||||
| @@ -222,15 +230,27 @@ const sidebarOpen = useStorage('mainSidebarOpen', true) | ||||
|                 <div class="flex items-center justify-between w-full"> | ||||
|                   <div class="font-semibold text-xl">Inbox</div> | ||||
|                   <div class="ml-auto"> | ||||
|                     <router-link :to="{ name: 'search' }"> | ||||
|                       <div class="flex items-center bg-accent p-2 rounded-full"> | ||||
|                         <Search | ||||
|                           class="transition-transform duration-200 hover:scale-110 cursor-pointer" | ||||
|                     <div class="flex items-center space-x-2"> | ||||
|                       <div | ||||
|                         class="flex items-center bg-accent p-2 rounded-full cursor-pointer" | ||||
|                         @click="emit('createConversation')" | ||||
|                       > | ||||
|                         <Plus | ||||
|                           class="transition-transform duration-200 hover:scale-110" | ||||
|                           size="15" | ||||
|                           stroke-width="2.5" | ||||
|                         /> | ||||
|                       </div> | ||||
|                     </router-link> | ||||
|                       <router-link :to="{ name: 'search' }"> | ||||
|                         <div class="flex items-center bg-accent p-2 rounded-full"> | ||||
|                           <Search | ||||
|                             class="transition-transform duration-200 hover:scale-110 cursor-pointer" | ||||
|                             size="15" | ||||
|                             stroke-width="2.5" | ||||
|                           /> | ||||
|                         </div> | ||||
|                       </router-link> | ||||
|                     </div> | ||||
|                   </div> | ||||
|                 </div> | ||||
|               </SidebarMenuButton> | ||||
| @@ -269,7 +289,12 @@ const sidebarOpen = useStorage('mainSidebarOpen', true) | ||||
|               </SidebarMenuItem> | ||||
|  | ||||
|               <!-- Team Inboxes --> | ||||
|               <Collapsible defaultOpen class="group/collapsible" v-if="userTeams.length"> | ||||
|               <Collapsible | ||||
|                 defaultOpen | ||||
|                 class="group/collapsible" | ||||
|                 v-if="userTeams.length" | ||||
|                 v-model:open="teamInboxOpen" | ||||
|               > | ||||
|                 <SidebarMenuItem> | ||||
|                   <CollapsibleTrigger as-child> | ||||
|                     <SidebarMenuButton asChild> | ||||
| @@ -301,7 +326,7 @@ const sidebarOpen = useStorage('mainSidebarOpen', true) | ||||
|               </Collapsible> | ||||
|  | ||||
|               <!-- Views --> | ||||
|               <Collapsible class="group/collapsible" defaultOpen> | ||||
|               <Collapsible class="group/collapsible" defaultOpen v-model:open="viewInboxOpen"> | ||||
|                 <SidebarMenuItem> | ||||
|                   <CollapsibleTrigger as-child> | ||||
|                     <SidebarMenuButton asChild> | ||||
| @@ -315,17 +340,14 @@ const sidebarOpen = useStorage('mainSidebarOpen', true) | ||||
|                             class="rounded-lg cursor-pointer opacity-0 transition-all duration-200 group-hover:opacity-100 hover:bg-gray-200 hover:shadow-sm text-gray-600 hover:text-gray-800 transform hover:scale-105 active:scale-100 p-1" | ||||
|                           /> | ||||
|                         </div> | ||||
|                         <ChevronRight | ||||
|                           class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" | ||||
|                           v-if="userViews.length" | ||||
|                         /> | ||||
|                       </router-link> | ||||
|                     </SidebarMenuButton> | ||||
|                   </CollapsibleTrigger> | ||||
|  | ||||
|                   <SidebarMenuAction> | ||||
|                     <ChevronRight | ||||
|                       class="transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" | ||||
|                       v-if="userViews.length" | ||||
|                     /> | ||||
|                   </SidebarMenuAction> | ||||
|  | ||||
|                   <CollapsibleContent> | ||||
|                     <SidebarMenuSub v-for="view in userViews" :key="view.id"> | ||||
|                       <SidebarMenuSubItem> | ||||
| @@ -335,25 +357,24 @@ const sidebarOpen = useStorage('mainSidebarOpen', true) | ||||
|                           asChild | ||||
|                         > | ||||
|                           <router-link :to="{ name: 'view-inbox', params: { viewID: view.id } }"> | ||||
|                             <span class="break-all w-24">{{ view.name }}</span> | ||||
|                             <span class="break-words w-32 truncate">{{ view.name }}</span> | ||||
|                             <SidebarMenuAction :showOnHover="true" class="mr-3"> | ||||
|                               <DropdownMenu> | ||||
|                                 <DropdownMenuTrigger asChild> | ||||
|                                   <EllipsisVertical /> | ||||
|                                 </DropdownMenuTrigger> | ||||
|                                 <DropdownMenuContent> | ||||
|                                   <DropdownMenuItem @click="() => editView(view)"> | ||||
|                                     <span>Edit</span> | ||||
|                                   </DropdownMenuItem> | ||||
|                                   <DropdownMenuItem @click="() => deleteView(view)"> | ||||
|                                     <span>Delete</span> | ||||
|                                   </DropdownMenuItem> | ||||
|                                 </DropdownMenuContent> | ||||
|                               </DropdownMenu> | ||||
|                             </SidebarMenuAction> | ||||
|                           </router-link> | ||||
|                         </SidebarMenuButton> | ||||
|  | ||||
|                         <SidebarMenuAction> | ||||
|                           <DropdownMenu> | ||||
|                             <DropdownMenuTrigger asChild> | ||||
|                               <EllipsisVertical /> | ||||
|                             </DropdownMenuTrigger> | ||||
|                             <DropdownMenuContent> | ||||
|                               <DropdownMenuItem @click="() => editView(view)"> | ||||
|                                 <span>Edit</span> | ||||
|                               </DropdownMenuItem> | ||||
|                               <DropdownMenuItem @click="() => deleteView(view)"> | ||||
|                                 <span>Delete</span> | ||||
|                               </DropdownMenuItem> | ||||
|                             </DropdownMenuContent> | ||||
|                           </DropdownMenu> | ||||
|                         </SidebarMenuAction> | ||||
|                       </SidebarMenuSubItem> | ||||
|                     </SidebarMenuSub> | ||||
|                   </CollapsibleContent> | ||||
|   | ||||
| @@ -1,82 +1,99 @@ | ||||
| <template> | ||||
|     <DropdownMenu> | ||||
|         <DropdownMenuTrigger as-child> | ||||
|             <SidebarMenuButton size="lg" | ||||
|                 class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground p-0"> | ||||
|                 <Avatar class="h-8 w-8 rounded-lg"> | ||||
|                     <AvatarImage :src="userStore.avatar" alt="Abhinav" /> | ||||
|                     <AvatarFallback class="rounded-lg"> | ||||
|                         {{ userStore.getInitials }} | ||||
|                     </AvatarFallback> | ||||
|                 </Avatar> | ||||
|                 <div class="grid flex-1 text-left text-sm leading-tight"> | ||||
|                     <span class="truncate font-semibold">{{ userStore.getFullName }}</span> | ||||
|                     <span class="truncate text-xs">{{ userStore.email }}</span> | ||||
|                 </div> | ||||
|                 <ChevronsUpDown class="ml-auto size-4" /> | ||||
|             </SidebarMenuButton> | ||||
|         </DropdownMenuTrigger> | ||||
|         <DropdownMenuContent class="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg" side="bottom" | ||||
|             :side-offset="4"> | ||||
|             <DropdownMenuLabel class="p-0 font-normal"> | ||||
|                 <div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm"> | ||||
|                     <Avatar class="h-8 w-8 rounded-lg"> | ||||
|                         <AvatarImage :src="userStore.avatar" alt="Abhinav" /> | ||||
|                         <AvatarFallback class="rounded-lg"> | ||||
|                             {{ userStore.getInitials }} | ||||
|                         </AvatarFallback> | ||||
|                     </Avatar> | ||||
|                     <div class="grid flex-1 text-left text-sm leading-tight"> | ||||
|                         <span class="truncate font-semibold">{{ userStore.getFullName }}</span> | ||||
|                         <span class="truncate text-xs">{{ userStore.email }}</span> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </DropdownMenuLabel> | ||||
|             <DropdownMenuSeparator /> | ||||
|             <DropdownMenuGroup> | ||||
|                 <DropdownMenuItem> | ||||
|                     <router-link to="/account" class="flex items-center"> | ||||
|                         <CircleUserRound size="18" class="mr-2" /> | ||||
|                         Account | ||||
|                     </router-link> | ||||
|                 </DropdownMenuItem> | ||||
|             </DropdownMenuGroup> | ||||
|             <DropdownMenuSeparator /> | ||||
|             <DropdownMenuItem @click="logout"> | ||||
|                 <LogOut size="18" class="mr-2" /> | ||||
|                 Log out | ||||
|             </DropdownMenuItem> | ||||
|         </DropdownMenuContent> | ||||
|     </DropdownMenu> | ||||
|   <DropdownMenu> | ||||
|     <DropdownMenuTrigger as-child> | ||||
|       <SidebarMenuButton | ||||
|         size="lg" | ||||
|         class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground p-0" | ||||
|       > | ||||
|         <Avatar class="h-8 w-8 rounded-lg relative overflow-visible"> | ||||
|           <AvatarImage :src="userStore.avatar" alt="" class="rounded-lg" /> | ||||
|           <AvatarFallback class="rounded-lg"> | ||||
|             {{ userStore.getInitials }} | ||||
|           </AvatarFallback> | ||||
|           <div | ||||
|             class="absolute bottom-0 right-0 h-2.5 w-2.5 rounded-full border border-background" | ||||
|             :class="{ | ||||
|               'bg-green-500': userStore.user.availability_status === 'online', | ||||
|               'bg-amber-500': | ||||
|                 userStore.user.availability_status === 'away' || | ||||
|                 userStore.user.availability_status === 'away_manual', | ||||
|               'bg-gray-400': userStore.user.availability_status === 'offline' | ||||
|             }" | ||||
|           ></div> | ||||
|         </Avatar> | ||||
|         <div class="grid flex-1 text-left text-sm leading-tight"> | ||||
|           <span class="truncate font-semibold">{{ userStore.getFullName }}</span> | ||||
|           <span class="truncate text-xs">{{ userStore.email }}</span> | ||||
|         </div> | ||||
|         <ChevronsUpDown class="ml-auto size-4" /> | ||||
|       </SidebarMenuButton> | ||||
|     </DropdownMenuTrigger> | ||||
|     <DropdownMenuContent | ||||
|       class="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg" | ||||
|       side="bottom" | ||||
|       :side-offset="4" | ||||
|     > | ||||
|       <DropdownMenuLabel class="p-0 font-normal space-y-1"> | ||||
|         <div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm"> | ||||
|           <Avatar class="h-8 w-8 rounded-lg"> | ||||
|             <AvatarImage :src="userStore.avatar" alt="Abhinav" /> | ||||
|             <AvatarFallback class="rounded-lg"> | ||||
|               {{ userStore.getInitials }} | ||||
|             </AvatarFallback> | ||||
|           </Avatar> | ||||
|           <div class="grid flex-1 text-left text-sm leading-tight"> | ||||
|             <span class="truncate font-semibold">{{ userStore.getFullName }}</span> | ||||
|             <span class="truncate text-xs">{{ userStore.email }}</span> | ||||
|           </div> | ||||
|         </div> | ||||
|         <div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm justify-between"> | ||||
|           <span class="text-muted-foreground">Away</span> | ||||
|           <Switch | ||||
|             :checked=" | ||||
|               userStore.user.availability_status === 'away' || | ||||
|               userStore.user.availability_status === 'away_manual' | ||||
|             " | ||||
|             @update:checked="(val) => userStore.updateUserAvailability(val ? 'away' : 'online')" | ||||
|           /> | ||||
|         </div> | ||||
|       </DropdownMenuLabel> | ||||
|       <DropdownMenuSeparator /> | ||||
|       <DropdownMenuGroup> | ||||
|         <DropdownMenuItem @click.prevent="router.push({ name: 'account' })"> | ||||
|           <CircleUserRound size="18" class="mr-2" /> | ||||
|           Account | ||||
|         </DropdownMenuItem> | ||||
|       </DropdownMenuGroup> | ||||
|       <DropdownMenuSeparator /> | ||||
|       <DropdownMenuItem @click="logout"> | ||||
|         <LogOut size="18" class="mr-2" /> | ||||
|         Log out | ||||
|       </DropdownMenuItem> | ||||
|     </DropdownMenuContent> | ||||
|   </DropdownMenu> | ||||
| </template> | ||||
|  | ||||
| <script setup> | ||||
| import { | ||||
|     DropdownMenu, | ||||
|     DropdownMenuContent, | ||||
|     DropdownMenuGroup, | ||||
|     DropdownMenuItem, | ||||
|     DropdownMenuLabel, | ||||
|     DropdownMenuSeparator, | ||||
|     DropdownMenuTrigger, | ||||
|   DropdownMenu, | ||||
|   DropdownMenuContent, | ||||
|   DropdownMenuGroup, | ||||
|   DropdownMenuItem, | ||||
|   DropdownMenuLabel, | ||||
|   DropdownMenuSeparator, | ||||
|   DropdownMenuTrigger | ||||
| } from '@/components/ui/dropdown-menu' | ||||
| import { | ||||
|     SidebarMenuButton, | ||||
| } from '@/components/ui/sidebar' | ||||
| import { | ||||
|     Avatar, | ||||
|     AvatarFallback, | ||||
|     AvatarImage, | ||||
| } from '@/components/ui/avatar' | ||||
| import { | ||||
|     ChevronsUpDown, | ||||
|     CircleUserRound, | ||||
|     LogOut, | ||||
| } from 'lucide-vue-next' | ||||
| import { SidebarMenuButton } from '@/components/ui/sidebar' | ||||
| import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' | ||||
| import { Switch } from '@/components/ui/switch' | ||||
| import { ChevronsUpDown, CircleUserRound, LogOut } from 'lucide-vue-next' | ||||
| import { useUserStore } from '@/stores/user' | ||||
| import { useRouter } from 'vue-router' | ||||
|  | ||||
| const userStore = useUserStore() | ||||
| const router = useRouter() | ||||
|  | ||||
| const logout = () => { | ||||
|     window.location.href = '/logout' | ||||
|   window.location.href = '/logout' | ||||
| } | ||||
| </script> | ||||
| @@ -3,6 +3,7 @@ import { Primitive } from 'radix-vue' | ||||
| import { buttonVariants } from '.' | ||||
| import { cn } from '@/lib/utils' | ||||
| import { ref, computed } from 'vue' | ||||
| import { DotLoader } from '@/components/ui/loader' | ||||
|  | ||||
| const props = defineProps({ | ||||
|   variant: { type: null, required: false }, | ||||
| @@ -29,11 +30,7 @@ const computedClass = computed(() => { | ||||
|     :class="computedClass" | ||||
|     :disabled="isLoading || isDisabled" | ||||
|   > | ||||
|     <span v-if="isLoading" class="dot-loader"> | ||||
|       <span class="dot"></span> | ||||
|       <span class="dot"></span> | ||||
|       <span class="dot"></span> | ||||
|     </span> | ||||
|     <DotLoader v-if="isLoading" /> | ||||
|     <slot v-else /> | ||||
|   </Primitive> | ||||
| </template> | ||||
|   | ||||
| @@ -1,94 +0,0 @@ | ||||
| <script setup> | ||||
| import { VisDonut, VisSingleContainer } from '@unovis/vue' | ||||
| import { Donut } from '@unovis/ts' | ||||
| import { computed, ref } from 'vue' | ||||
| import { useMounted } from '@vueuse/core' | ||||
| import { ChartSingleTooltip, defaultColors } from '@/components/ui/chart' | ||||
| import { cn } from '@/lib/utils' | ||||
|  | ||||
| const props = defineProps({ | ||||
|   data: { type: Array, required: true }, | ||||
|   colors: { type: Array, required: false }, | ||||
|   index: { type: null, required: true }, | ||||
|   margin: { | ||||
|     type: null, | ||||
|     required: false, | ||||
|     default: () => ({ top: 0, bottom: 0, left: 0, right: 0 }) | ||||
|   }, | ||||
|   showLegend: { type: Boolean, required: false, default: true }, | ||||
|   showTooltip: { type: Boolean, required: false, default: true }, | ||||
|   filterOpacity: { type: Number, required: false, default: 0.2 }, | ||||
|   category: { type: String, required: true }, | ||||
|   type: { type: String, required: false, default: 'donut' }, | ||||
|   sortFunction: { type: Function, required: false, default: () => undefined }, | ||||
|   valueFormatter: { type: Function, required: false, default: (tick) => `${tick}` }, | ||||
|   customTooltip: { type: null, required: false } | ||||
| }) | ||||
|  | ||||
| const category = computed(() => props.category) | ||||
| const index = computed(() => props.index) | ||||
|  | ||||
| const isMounted = useMounted() | ||||
| const activeSegmentKey = ref() | ||||
| const colors = computed(() => | ||||
|   props.colors?.length | ||||
|     ? props.colors | ||||
|     : defaultColors(props.data.filter((d) => d[props.category]).filter(Boolean).length) | ||||
| ) | ||||
| const legendItems = computed(() => | ||||
|   props.data.map((item, i) => ({ | ||||
|     name: item[props.index], | ||||
|     color: colors.value[i], | ||||
|     inactive: false | ||||
|   })) | ||||
| ) | ||||
|  | ||||
| const totalValue = computed(() => | ||||
|   props.data.reduce((prev, curr) => { | ||||
|     return prev + curr[props.category] | ||||
|   }, 0) | ||||
| ) | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <div :class="cn('w-full h-48 flex flex-col items-end', $attrs.class ?? '')"> | ||||
|     <VisSingleContainer | ||||
|       :style="{ height: isMounted ? '100%' : 'auto' }" | ||||
|       :margin="{ left: 20, right: 20 }" | ||||
|       :data="data" | ||||
|     > | ||||
|       <ChartSingleTooltip | ||||
|         :selector="Donut.selectors.segment" | ||||
|         :index="category" | ||||
|         :items="legendItems" | ||||
|         :value-formatter="valueFormatter" | ||||
|         :custom-tooltip="customTooltip" | ||||
|       /> | ||||
|  | ||||
|       <VisDonut | ||||
|         :value="(d) => d[category]" | ||||
|         :sort-function="sortFunction" | ||||
|         :color="colors" | ||||
|         :arc-width="type === 'donut' ? 20 : 0" | ||||
|         :show-background="false" | ||||
|         :central-label="type === 'donut' ? valueFormatter(totalValue) : ''" | ||||
|         :events="{ | ||||
|           [Donut.selectors.segment]: { | ||||
|             click: (d, ev, i, elements) => { | ||||
|               if (d?.data?.[index] === activeSegmentKey) { | ||||
|                 activeSegmentKey = undefined | ||||
|                 elements.forEach((el) => (el.style.opacity = '1')) | ||||
|               } else { | ||||
|                 activeSegmentKey = d?.data?.[index] | ||||
|                 elements.forEach((el) => (el.style.opacity = `${filterOpacity}`)) | ||||
|                 elements[i].style.opacity = '1' | ||||
|               } | ||||
|             } | ||||
|           } | ||||
|         }" | ||||
|       /> | ||||
|  | ||||
|       <slot /> | ||||
|     </VisSingleContainer> | ||||
|   </div> | ||||
| </template> | ||||
| @@ -1 +0,0 @@ | ||||
| export { default as DonutChart } from './DonutChart.vue' | ||||
| @@ -1,9 +1,7 @@ | ||||
| <template> | ||||
|   <div class="flex flex-col items-center justify-center text-gray-600 dark:text-gray-300"> | ||||
|     <span class="dot-loader"> | ||||
|       <span class="dot"></span> | ||||
|       <span class="dot"></span> | ||||
|       <span class="dot"></span> | ||||
|     </span> | ||||
|   </div> | ||||
|   <span class="dot-loader"> | ||||
|     <span class="dot"></span> | ||||
|     <span class="dot"></span> | ||||
|     <span class="dot"></span> | ||||
|   </span> | ||||
| </template> | ||||
|   | ||||
| @@ -1,43 +0,0 @@ | ||||
| <script setup> | ||||
| import { computed } from 'vue' | ||||
| import { SplitterResizeHandle, useForwardPropsEmits } from 'radix-vue' | ||||
| import { DragHandleDots2Icon } from '@radix-icons/vue' | ||||
| import { cn } from '@/lib/utils' | ||||
|  | ||||
| const props = defineProps({ | ||||
|   id: { type: String, required: false }, | ||||
|   hitAreaMargins: { type: Object, required: false }, | ||||
|   tabindex: { type: Number, required: false }, | ||||
|   disabled: { type: Boolean, required: false }, | ||||
|   asChild: { type: Boolean, required: false }, | ||||
|   as: { type: null, required: false }, | ||||
|   class: { type: null, required: false }, | ||||
|   withHandle: { type: Boolean, required: false } | ||||
| }) | ||||
| const emits = defineEmits(['dragging']) | ||||
|  | ||||
| const delegatedProps = computed(() => { | ||||
|   const { class: _, ...delegated } = props | ||||
|   return delegated | ||||
| }) | ||||
|  | ||||
| const forwarded = useForwardPropsEmits(delegatedProps, emits) | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <SplitterResizeHandle | ||||
|     v-bind="forwarded" | ||||
|     :class=" | ||||
|       cn( | ||||
|         'relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 [&[data-orientation=vertical]]:h-px [&[data-orientation=vertical]]:w-full [&[data-orientation=vertical]]:after:left-0 [&[data-orientation=vertical]]:after:h-1 [&[data-orientation=vertical]]:after:w-full [&[data-orientation=vertical]]:after:-translate-y-1/2 [&[data-orientation=vertical]]:after:translate-x-0 [&[data-orientation=vertical]>div]:rotate-90', | ||||
|         props.class | ||||
|       ) | ||||
|     " | ||||
|   > | ||||
|     <template v-if="props.withHandle"> | ||||
|       <div class="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border"> | ||||
|         <DragHandleDots2Icon class="h-2.5 w-2.5" /> | ||||
|       </div> | ||||
|     </template> | ||||
|   </SplitterResizeHandle> | ||||
| </template> | ||||
| @@ -1,33 +0,0 @@ | ||||
| <script setup> | ||||
| import { computed } from 'vue' | ||||
| import { SplitterGroup, useForwardPropsEmits } from 'radix-vue' | ||||
| import { cn } from '@/lib/utils' | ||||
|  | ||||
| const props = defineProps({ | ||||
|   id: { type: [String, null], required: false }, | ||||
|   autoSaveId: { type: [String, null], required: false }, | ||||
|   direction: { type: String, required: true }, | ||||
|   keyboardResizeBy: { type: [Number, null], required: false }, | ||||
|   storage: { type: Object, required: false }, | ||||
|   asChild: { type: Boolean, required: false }, | ||||
|   as: { type: null, required: false }, | ||||
|   class: { type: null, required: false } | ||||
| }) | ||||
| const emits = defineEmits(['layout']) | ||||
|  | ||||
| const delegatedProps = computed(() => { | ||||
|   const { class: _, ...delegated } = props | ||||
|   return delegated | ||||
| }) | ||||
|  | ||||
| const forwarded = useForwardPropsEmits(delegatedProps, emits) | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <SplitterGroup | ||||
|     v-bind="forwarded" | ||||
|     :class="cn('flex h-full w-full data-[panel-group-direction=vertical]:flex-col', props.class)" | ||||
|   > | ||||
|     <slot /> | ||||
|   </SplitterGroup> | ||||
| </template> | ||||
| @@ -1,3 +0,0 @@ | ||||
| export { default as ResizablePanelGroup } from './ResizablePanelGroup.vue' | ||||
| export { default as ResizableHandle } from './ResizableHandle.vue' | ||||
| export { SplitterPanel as ResizablePanel } from 'radix-vue' | ||||
| @@ -1,31 +0,0 @@ | ||||
| <script setup> | ||||
| import { computed } from 'vue' | ||||
| import { ScrollAreaCorner, ScrollAreaRoot, ScrollAreaViewport } from 'radix-vue' | ||||
| import ScrollBar from './ScrollBar.vue' | ||||
| import { cn } from '@/lib/utils' | ||||
|  | ||||
| const props = defineProps({ | ||||
|   type: { type: String, required: false }, | ||||
|   dir: { type: String, required: false }, | ||||
|   scrollHideDelay: { type: Number, required: false }, | ||||
|   asChild: { type: Boolean, required: false }, | ||||
|   as: { type: null, required: false }, | ||||
|   class: { type: null, required: false } | ||||
| }) | ||||
|  | ||||
| const delegatedProps = computed(() => { | ||||
|   const { class: _, ...delegated } = props | ||||
|  | ||||
|   return delegated | ||||
| }) | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <ScrollAreaRoot v-bind="delegatedProps" :class="cn('relative overflow-hidden', props.class)"> | ||||
|     <ScrollAreaViewport class="h-full w-full rounded-[inherit]"> | ||||
|       <slot /> | ||||
|     </ScrollAreaViewport> | ||||
|     <ScrollBar /> | ||||
|     <ScrollAreaCorner /> | ||||
|   </ScrollAreaRoot> | ||||
| </template> | ||||
| @@ -1,35 +0,0 @@ | ||||
| <script setup> | ||||
| import { computed } from 'vue' | ||||
| import { ScrollAreaScrollbar, ScrollAreaThumb } from 'radix-vue' | ||||
| import { cn } from '@/lib/utils' | ||||
|  | ||||
| const props = defineProps({ | ||||
|   orientation: { type: String, required: false, default: 'vertical' }, | ||||
|   forceMount: { type: Boolean, required: false }, | ||||
|   asChild: { type: Boolean, required: false }, | ||||
|   as: { type: null, required: false }, | ||||
|   class: { type: null, required: false } | ||||
| }) | ||||
|  | ||||
| const delegatedProps = computed(() => { | ||||
|   const { class: _, ...delegated } = props | ||||
|  | ||||
|   return delegated | ||||
| }) | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <ScrollAreaScrollbar | ||||
|     v-bind="delegatedProps" | ||||
|     :class=" | ||||
|       cn( | ||||
|         'flex touch-none select-none transition-colors', | ||||
|         orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-px', | ||||
|         orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-px', | ||||
|         props.class | ||||
|       ) | ||||
|     " | ||||
|   > | ||||
|     <ScrollAreaThumb class="relative flex-1 rounded-full bg-border" /> | ||||
|   </ScrollAreaScrollbar> | ||||
| </template> | ||||
| @@ -1,2 +0,0 @@ | ||||
| export { default as ScrollArea } from './ScrollArea.vue' | ||||
| export { default as ScrollBar } from './ScrollBar.vue' | ||||
| @@ -1,43 +0,0 @@ | ||||
| <script setup> | ||||
| import { computed, provide } from 'vue' | ||||
| import { ToggleGroupRoot, useForwardPropsEmits } from 'radix-vue' | ||||
| import { cn } from '@/lib/utils' | ||||
|  | ||||
| const props = defineProps({ | ||||
|   rovingFocus: { type: Boolean, required: false }, | ||||
|   disabled: { type: Boolean, required: false }, | ||||
|   orientation: { type: String, required: false }, | ||||
|   dir: { type: String, required: false }, | ||||
|   loop: { type: Boolean, required: false }, | ||||
|   asChild: { type: Boolean, required: false }, | ||||
|   as: { type: null, required: false }, | ||||
|   type: { type: null, required: false }, | ||||
|   modelValue: { type: null, required: false }, | ||||
|   defaultValue: { type: null, required: false }, | ||||
|   class: { type: null, required: false }, | ||||
|   variant: { type: null, required: false }, | ||||
|   size: { type: null, required: false } | ||||
| }) | ||||
| const emits = defineEmits(['update:modelValue']) | ||||
|  | ||||
| provide('toggleGroup', { | ||||
|   variant: props.variant, | ||||
|   size: props.size | ||||
| }) | ||||
|  | ||||
| const delegatedProps = computed(() => { | ||||
|   const { class: _, ...delegated } = props | ||||
|   return delegated | ||||
| }) | ||||
|  | ||||
| const forwarded = useForwardPropsEmits(delegatedProps, emits) | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <ToggleGroupRoot | ||||
|     v-bind="forwarded" | ||||
|     :class="cn('flex items-center justify-center gap-1', props.class)" | ||||
|   > | ||||
|     <slot /> | ||||
|   </ToggleGroupRoot> | ||||
| </template> | ||||
| @@ -1,44 +0,0 @@ | ||||
| <script setup> | ||||
| import { computed, inject } from 'vue' | ||||
| import { ToggleGroupItem, useForwardProps } from 'radix-vue' | ||||
| import { toggleVariants } from '@/components/ui/toggle' | ||||
| import { cn } from '@/lib/utils' | ||||
|  | ||||
| const props = defineProps({ | ||||
|   value: { type: String, required: true }, | ||||
|   defaultValue: { type: Boolean, required: false }, | ||||
|   pressed: { type: Boolean, required: false }, | ||||
|   disabled: { type: Boolean, required: false }, | ||||
|   asChild: { type: Boolean, required: false }, | ||||
|   as: { type: null, required: false }, | ||||
|   class: { type: null, required: false }, | ||||
|   variant: { type: null, required: false }, | ||||
|   size: { type: null, required: false } | ||||
| }) | ||||
|  | ||||
| const context = inject('toggleGroup') | ||||
|  | ||||
| const delegatedProps = computed(() => { | ||||
|   const { class: _, variant, size, ...delegated } = props | ||||
|   return delegated | ||||
| }) | ||||
|  | ||||
| const forwardedProps = useForwardProps(delegatedProps) | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <ToggleGroupItem | ||||
|     v-bind="forwardedProps" | ||||
|     :class=" | ||||
|       cn( | ||||
|         toggleVariants({ | ||||
|           variant: context?.variant || variant, | ||||
|           size: context?.size || size | ||||
|         }), | ||||
|         props.class | ||||
|       ) | ||||
|     " | ||||
|   > | ||||
|     <slot /> | ||||
|   </ToggleGroupItem> | ||||
| </template> | ||||
| @@ -1,2 +0,0 @@ | ||||
| export { default as ToggleGroup } from './ToggleGroup.vue' | ||||
| export { default as ToggleGroupItem } from './ToggleGroupItem.vue' | ||||
							
								
								
									
										25
									
								
								frontend/src/components/update/AppUpdate.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								frontend/src/components/update/AppUpdate.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,25 @@ | ||||
| <template> | ||||
|   <div | ||||
|     v-if="appSettingsStore.settings['app.update']?.update?.is_new" | ||||
|     class="p-2 mb-2 border-b bg-secondary text-secondary-foreground" | ||||
|   > | ||||
|     A new update is available: | ||||
|     {{ 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" | ||||
|     > | ||||
|       View details | ||||
|     </a> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script setup> | ||||
| import { useAppSettingsStore } from '@/stores/appSettings' | ||||
| const appSettingsStore = useAppSettingsStore() | ||||
| </script> | ||||
							
								
								
									
										59
									
								
								frontend/src/composables/useIdleDetection.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								frontend/src/composables/useIdleDetection.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,59 @@ | ||||
| import { ref, onMounted, onBeforeUnmount, watch } from 'vue' | ||||
| import { useUserStore } from '@/stores/user' | ||||
| import { debounce } from '@/utils/debounce' | ||||
| import { useStorage } from '@vueuse/core' | ||||
|  | ||||
| export function useIdleDetection () { | ||||
|     const userStore = useUserStore() | ||||
|     // 4 minutes | ||||
|     const AWAY_THRESHOLD = 4 * 60 * 1000 | ||||
|     // 1 minute | ||||
|     const CHECK_INTERVAL = 60 * 1000 | ||||
|  | ||||
|     // Store last activity time in localStorage to sync across tabs | ||||
|     const lastActivity = useStorage('last_active', Date.now()) | ||||
|     const timer = ref(null) | ||||
|  | ||||
|     function resetTimer () { | ||||
|         if (userStore.user.availability_status === 'away' || userStore.user.availability_status === 'offline') { | ||||
|             userStore.updateUserAvailability('online', false) | ||||
|         } | ||||
|         const now = Date.now() | ||||
|         if (lastActivity.value < now) { | ||||
|             lastActivity.value = now | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     const debouncedResetTimer = debounce(resetTimer, 200) | ||||
|  | ||||
|     function checkIdle () { | ||||
|         if (Date.now() - lastActivity.value > AWAY_THRESHOLD && | ||||
|             userStore.user.availability_status === 'online') { | ||||
|             userStore.updateUserAvailability('away', false) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     onMounted(() => { | ||||
|         window.addEventListener('mousemove', debouncedResetTimer) | ||||
|         window.addEventListener('keypress', debouncedResetTimer) | ||||
|         window.addEventListener('click', debouncedResetTimer) | ||||
|         timer.value = setInterval(checkIdle, CHECK_INTERVAL) | ||||
|     }) | ||||
|  | ||||
|     onBeforeUnmount(() => { | ||||
|         window.removeEventListener('mousemove', debouncedResetTimer) | ||||
|         window.removeEventListener('keypress', debouncedResetTimer) | ||||
|         window.removeEventListener('click', debouncedResetTimer) | ||||
|         if (timer.value) { | ||||
|             clearInterval(timer.value) | ||||
|             timer.value = null | ||||
|         } | ||||
|     }) | ||||
|  | ||||
|     // Watch for lastActivity changes in localStorage to handle multi-tab sync | ||||
|     watch(lastActivity, (newVal, oldVal) => { | ||||
|         if (newVal > oldVal) { | ||||
|             resetTimer() | ||||
|         } | ||||
|     }) | ||||
| } | ||||
| @@ -18,5 +18,5 @@ export function useSla (dueAt, actualAt) { | ||||
|             clearInterval(intervalId) | ||||
|         }) | ||||
|     }) | ||||
|     return { sla, updateSla } | ||||
|     return sla | ||||
| } | ||||
|   | ||||
| @@ -10,7 +10,6 @@ | ||||
|           <div class="flex items-center justify-between"> | ||||
|             <div class="flex gap-5"> | ||||
|               <div class="w-48"> | ||||
|  | ||||
|                 <!-- Type --> | ||||
|                 <Select | ||||
|                   v-model="action.type" | ||||
| @@ -109,15 +108,13 @@ | ||||
|           </div> | ||||
|  | ||||
|           <div | ||||
|             class="box p-2 h-96 min-h-96" | ||||
|             v-if="action.type && conversationActions[action.type]?.type === 'richtext'" | ||||
|             class="pl-0 shadow" | ||||
|           > | ||||
|             <QuillEditor | ||||
|               theme="snow" | ||||
|               v-model:content="action.value[0]" | ||||
|               contentType="html" | ||||
|               @update:content="(value) => handleValueChange(value, index)" | ||||
|               class="h-32 mb-12" | ||||
|             <Editor | ||||
|               v-model:htmlContent="action.value[0]" | ||||
|               @update:htmlContent="(value) => handleEditorChange(value, index)" | ||||
|               :placeholder="'Shift + Enter to add new line'" | ||||
|             /> | ||||
|           </div> | ||||
|         </div> | ||||
| @@ -142,12 +139,12 @@ import { | ||||
|   SelectTrigger, | ||||
|   SelectValue | ||||
| } from '@/components/ui/select' | ||||
| import { QuillEditor } from '@vueup/vue-quill' | ||||
| import '@vueup/vue-quill/dist/vue-quill.snow.css' | ||||
| import ComboBox from '@/components/ui/combobox/ComboBox.vue' | ||||
| import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' | ||||
| import { SelectTag } from '@/components/ui/select' | ||||
| import { useConversationFilters } from '@/composables/useConversationFilters' | ||||
| import { getTextFromHTML } from '@/utils/strings.js' | ||||
| import Editor from '@/features/conversation/ConversationTextEditor.vue' | ||||
|  | ||||
| const props = defineProps({ | ||||
|   actions: { | ||||
| @@ -175,6 +172,16 @@ const handleValueChange = (value, index) => { | ||||
|   emitUpdate(index) | ||||
| } | ||||
|  | ||||
| const handleEditorChange = (value, index) => { | ||||
|   // If text is empty, set HTML to empty string | ||||
|   const textContent = getTextFromHTML(value) | ||||
|   if (textContent.length === 0) { | ||||
|     value = '' | ||||
|   } | ||||
|   actions.value[index].value = [value] | ||||
|   emitUpdate(index) | ||||
| } | ||||
|  | ||||
| const removeAction = (index) => { | ||||
|   emit('remove-action', index) | ||||
| } | ||||
|   | ||||
| @@ -31,7 +31,7 @@ | ||||
|           </template> | ||||
|         </draggable> | ||||
|       </div> | ||||
|       <div v-else> | ||||
|       <div v-else class="space-y-5"> | ||||
|         <RuleList | ||||
|           v-for="rule in rules" | ||||
|           :key="rule.id" | ||||
|   | ||||
| @@ -108,19 +108,6 @@ | ||||
|               placeholder="Select tag" | ||||
|             /> | ||||
|           </div> | ||||
|  | ||||
|           <div | ||||
|             v-if="action.type && config.actions[action.type]?.type === 'richtext'" | ||||
|             class="pl-0 shadow" | ||||
|           > | ||||
|             <QuillEditor | ||||
|               v-model:content="action.value[0]" | ||||
|               theme="snow" | ||||
|               contentType="html" | ||||
|               @update:content="(value) => updateValue(value, index)" | ||||
|               class="h-32 mb-12" | ||||
|             /> | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
| @@ -139,14 +126,12 @@ import { | ||||
|   SelectTrigger, | ||||
|   SelectValue | ||||
| } from '@/components/ui/select' | ||||
| import { QuillEditor } from '@vueup/vue-quill' | ||||
| import '@vueup/vue-quill/dist/vue-quill.snow.css' | ||||
| import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar' | ||||
| import { SelectTag } from '@/components/ui/select' | ||||
| import { useTagStore } from '@/stores/tag' | ||||
| import ComboBox from '@/components/ui/combobox/ComboBox.vue' | ||||
|  | ||||
| const model = defineModel({ | ||||
| const model = defineModel("actions", { | ||||
|   type: Array, | ||||
|   required: true, | ||||
|   default: () => [] | ||||
|   | ||||
| @@ -13,16 +13,25 @@ | ||||
|  | ||||
|     <FormField v-slot="{ componentField }" name="message_content"> | ||||
|       <FormItem> | ||||
|         <FormLabel>Response to be sent when macro is used</FormLabel> | ||||
|         <FormLabel>Response to be sent when macro is used (optional)</FormLabel> | ||||
|         <FormControl> | ||||
|           <QuillEditor | ||||
|             v-model:content="componentField.modelValue" | ||||
|             placeholder="Add a response (optional)" | ||||
|             theme="snow" | ||||
|             contentType="html" | ||||
|             class="h-32 mb-12" | ||||
|             @update:content="(value) => componentField.onChange(value)" | ||||
|           /> | ||||
|           <div class="box p-2 h-96 min-h-96"> | ||||
|             <Editor | ||||
|               v-model:htmlContent="componentField.modelValue" | ||||
|               @update:htmlContent="(value) => componentField.onChange(value)" | ||||
|               :placeholder="'Shift + Enter to add new line'" | ||||
|             /> | ||||
|           </div> | ||||
|         </FormControl> | ||||
|         <FormMessage /> | ||||
|       </FormItem> | ||||
|     </FormField> | ||||
|  | ||||
|     <FormField v-slot="{ componentField }" name="actions"> | ||||
|       <FormItem> | ||||
|         <FormLabel> Actions (optional)</FormLabel> | ||||
|         <FormControl> | ||||
|           <ActionBuilder v-model:actions="componentField.modelValue" :config="actionConfig" @update:actions="(value) => componentField.onChange(value)" /> | ||||
|         </FormControl> | ||||
|         <FormMessage /> | ||||
|       </FormItem> | ||||
| @@ -106,16 +115,6 @@ | ||||
|         <FormMessage /> | ||||
|       </FormItem> | ||||
|     </FormField> | ||||
|  | ||||
|     <FormField v-slot="{ componentField }" name="actions"> | ||||
|       <FormItem> | ||||
|         <FormLabel> Actions </FormLabel> | ||||
|         <FormControl> | ||||
|           <ActionBuilder v-bind="componentField" :config="actionConfig" /> | ||||
|         </FormControl> | ||||
|         <FormMessage /> | ||||
|       </FormItem> | ||||
|     </FormField> | ||||
|     <Button type="submit" :isLoading="isLoading">{{ submitLabel }}</Button> | ||||
|   </form> | ||||
| </template> | ||||
| @@ -133,9 +132,8 @@ import ActionBuilder from '@/features/admin/macros/ActionBuilder.vue' | ||||
| import { useConversationFilters } from '@/composables/useConversationFilters' | ||||
| import { useUsersStore } from '@/stores/users' | ||||
| import { useTeamStore } from '@/stores/team' | ||||
| import { getTextFromHTML } from '@/utils/strings.js' | ||||
| import { formSchema } from './formSchema.js' | ||||
| import { QuillEditor } from '@vueup/vue-quill' | ||||
| import '@vueup/vue-quill/dist/vue-quill.snow.css' | ||||
| import { | ||||
|   Select, | ||||
|   SelectContent, | ||||
| @@ -145,6 +143,7 @@ import { | ||||
|   SelectValue | ||||
| } from '@/components/ui/select' | ||||
| import ComboBox from '@/components/ui/combobox/ComboBox.vue' | ||||
| import Editor from '@/features/conversation/ConversationTextEditor.vue' | ||||
|  | ||||
| const { macroActions } = useConversationFilters() | ||||
| const formLoading = ref(false) | ||||
| @@ -181,6 +180,11 @@ const actionConfig = ref({ | ||||
| }) | ||||
|  | ||||
| const onSubmit = form.handleSubmit(async (values) => { | ||||
|   // If the text of HTML is empty then set the HTML to empty string | ||||
|   const textContent = getTextFromHTML(values.message_content) | ||||
|   if (textContent.length === 0) { | ||||
|     values.message_content = '' | ||||
|   } | ||||
|   props.submitForm(values) | ||||
| }) | ||||
|  | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| import * as z from 'zod' | ||||
| import { getTextFromHTML } from '@/utils/strings.js' | ||||
|  | ||||
| const actionSchema = z.array( | ||||
|   z.object({ | ||||
| @@ -10,8 +11,42 @@ const actionSchema = z.array( | ||||
| export const formSchema = z.object({ | ||||
|   name: z.string().min(1, 'Macro name is required'), | ||||
|   message_content: z.string().optional(), | ||||
|   actions: actionSchema, | ||||
|   actions: actionSchema.optional().default([]), // Default to empty array if not provided | ||||
|   visibility: z.enum(['all', 'team', 'user']), | ||||
|   team_id: z.string().nullable().optional(), | ||||
|   user_id: z.string().nullable().optional(), | ||||
| }) | ||||
|   .refine( | ||||
|     (data) => { | ||||
|       // Check if message_content has non-empty text after stripping HTML | ||||
|       const hasMessageContent = getTextFromHTML(data.message_content || '').trim().length > 0 | ||||
|       // Check if actions has at least one valid action | ||||
|       const hasValidActions = data.actions && data.actions.length > 0 | ||||
|       // Either message content or actions must be valid | ||||
|       return hasMessageContent || hasValidActions | ||||
|     }, | ||||
|     { | ||||
|       message: 'Either message content or actions are required', | ||||
|       // Field path to highlight | ||||
|       path: ['message_content'], | ||||
|     } | ||||
|   ) | ||||
|   .refine( | ||||
|     (data) => { | ||||
|       // If visibility is 'team', team_id is required | ||||
|       if (data.visibility === 'team' && !data.team_id) { | ||||
|         return false | ||||
|       } | ||||
|       // If visibility is 'user', user_id is required | ||||
|       if (data.visibility === 'user' && !data.user_id) { | ||||
|         return false | ||||
|       } | ||||
|       // Otherwise, validation passes | ||||
|       return true | ||||
|     }, | ||||
|     { | ||||
|       message: 'team is required when visibility is "team", and user is required when visibility is "user"', | ||||
|       // Field path to highlight | ||||
|       path: ['visibility'], | ||||
|     } | ||||
|   ) | ||||
| @@ -65,6 +65,7 @@ | ||||
|           <Input type="number" placeholder="2" v-bind="componentField" /> | ||||
|         </FormControl> | ||||
|         <FormMessage /> | ||||
|         <FormDescription> Maximum concurrent connections to the server. </FormDescription> | ||||
|       </FormItem> | ||||
|     </FormField> | ||||
|  | ||||
| @@ -76,6 +77,10 @@ | ||||
|           <Input type="text" placeholder="15s" v-bind="componentField" /> | ||||
|         </FormControl> | ||||
|         <FormMessage /> | ||||
|         <FormDescription> | ||||
|           Time to wait for new activity on a connection before closing it and removing it from the | ||||
|           pool (s for second, m for minute) | ||||
|         </FormDescription> | ||||
|       </FormItem> | ||||
|     </FormField> | ||||
|  | ||||
| @@ -87,6 +92,10 @@ | ||||
|           <Input type="text" placeholder="5s" v-bind="componentField" /> | ||||
|         </FormControl> | ||||
|         <FormMessage /> | ||||
|         <FormDescription> | ||||
|           Time to wait for new activity on a connection before closing it and removing it from the | ||||
|           pool (s for second, m for minute, h for hour). | ||||
|         </FormDescription> | ||||
|       </FormItem> | ||||
|     </FormField> | ||||
|  | ||||
| @@ -139,6 +148,7 @@ | ||||
|           <Input type="number" placeholder="2" v-bind="componentField" /> | ||||
|         </FormControl> | ||||
|         <FormMessage /> | ||||
|         <FormDescription> Number of times to retry when a message fails. </FormDescription> | ||||
|       </FormItem> | ||||
|     </FormField> | ||||
|  | ||||
|   | ||||
| @@ -3,7 +3,7 @@ import { isGoDuration } from '@/utils/strings'; | ||||
|  | ||||
| export const smtpConfigSchema = z.object({ | ||||
|     enabled: z.boolean().describe('Enabled status').default(false), | ||||
|     username: z.string().describe('SMTP username').email().nonempty({ | ||||
|     username: z.string().describe('SMTP username').nonempty({ | ||||
|         message: "SMTP username is required" | ||||
|     }), | ||||
|     host: z.string().describe('SMTP host').nonempty({ | ||||
| @@ -51,8 +51,8 @@ export const smtpConfigSchema = z.object({ | ||||
|     auth_protocol: z | ||||
|         .enum(['plain', 'login', 'cram', 'none']) | ||||
|         .describe('Authentication protocol'), | ||||
|     email_address: z.string().describe('Email address').email().nonempty({ | ||||
|         message: "Email address is required" | ||||
|     email_address: z.string().describe('From email address with name (e.g., "Name <email@example.com>")').nonempty({ | ||||
|         message: "From email address is required" | ||||
|     }), | ||||
|     max_msg_retries: z | ||||
|         .number({ | ||||
|   | ||||
| @@ -13,7 +13,11 @@ | ||||
|       <FormItem> | ||||
|         <FormLabel>Description</FormLabel> | ||||
|         <FormControl> | ||||
|           <Input type="text" placeholder="This role is for all support agents" v-bind="componentField" /> | ||||
|           <Input | ||||
|             type="text" | ||||
|             placeholder="This role is for all support agents" | ||||
|             v-bind="componentField" | ||||
|           /> | ||||
|         </FormControl> | ||||
|         <FormMessage /> | ||||
|       </FormItem> | ||||
| @@ -24,13 +28,19 @@ | ||||
|     <div v-for="entity in permissions" :key="entity.name" class="box p-4"> | ||||
|       <p class="text-lg mb-5">{{ entity.name }}</p> | ||||
|       <div class="space-y-4"> | ||||
|         <FormField v-for="permission in entity.permissions" :key="permission.name" type="checkbox" | ||||
|           :name="permission.name"> | ||||
|         <FormField | ||||
|           v-for="permission in entity.permissions" | ||||
|           :key="permission.name" | ||||
|           type="checkbox" | ||||
|           :name="permission.name" | ||||
|         > | ||||
|           <FormItem class="flex flex-col gap-y-5 space-y-0 rounded-lg"> | ||||
|             <div class="flex space-x-3"> | ||||
|               <FormControl> | ||||
|                 <Checkbox :checked="selectedPermissions.includes(permission.name)" | ||||
|                   @update:checked="(newValue) => handleChange(newValue, permission.name)" /> | ||||
|                 <Checkbox | ||||
|                   :checked="selectedPermissions.includes(permission.name)" | ||||
|                   @update:checked="(newValue) => handleChange(newValue, permission.name)" | ||||
|                 /> | ||||
|                 <FormLabel>{{ permission.label }}</FormLabel> | ||||
|               </FormControl> | ||||
|             </div> | ||||
| @@ -69,7 +79,7 @@ const props = defineProps({ | ||||
|   }, | ||||
|   isLoading: { | ||||
|     type: Boolean, | ||||
|     required: false, | ||||
|     required: false | ||||
|   } | ||||
| }) | ||||
|  | ||||
| @@ -77,7 +87,8 @@ const permissions = ref([ | ||||
|   { | ||||
|     name: 'Conversation', | ||||
|     permissions: [ | ||||
|       { name: 'conversations:read', label: 'View conversations' }, | ||||
|       { name: 'conversations:read', label: 'View conversation' }, | ||||
|       { name: 'conversations:write', label: 'Create conversation' }, | ||||
|       { name: 'conversations:read_assigned', label: 'View conversations assigned to me' }, | ||||
|       { name: 'conversations:read_all', label: 'View all conversations' }, | ||||
|       { name: 'conversations:read_unassigned', label: 'View all unassigned conversations' }, | ||||
| @@ -89,7 +100,7 @@ const permissions = ref([ | ||||
|       { name: 'conversations:update_tags', label: 'Add or remove conversation tags' }, | ||||
|       { name: 'messages:read', label: 'View conversation messages' }, | ||||
|       { name: 'messages:write', label: 'Send messages in conversations' }, | ||||
|       { name: 'view:manage', label: 'Create and manage conversation views' }, | ||||
|       { name: 'view:manage', label: 'Create and manage conversation views' } | ||||
|     ] | ||||
|   }, | ||||
|   { | ||||
| @@ -110,8 +121,9 @@ const permissions = ref([ | ||||
|       { name: 'reports:manage', label: 'Manage Reports' }, | ||||
|       { name: 'business_hours:manage', label: 'Manage Business Hours' }, | ||||
|       { name: 'sla:manage', label: 'Manage SLA Policies' }, | ||||
|       { name: 'ai:manage', label: 'Manage AI Features' } | ||||
|     ] | ||||
|   }, | ||||
|   } | ||||
| ]) | ||||
|  | ||||
| const selectedPermissions = ref([]) | ||||
|   | ||||
| @@ -1,14 +1,16 @@ | ||||
| <template> | ||||
|   <Dialog v-model:open="dialogOpen"> | ||||
|     <DropdownMenu> | ||||
|       <DropdownMenuTrigger | ||||
|         as-child | ||||
|         v-if="!CONVERSATION_DEFAULT_STATUSES_LIST.includes(props.status.name)" | ||||
|       > | ||||
|         <Button variant="ghost" class="w-8 h-8 p-0"> | ||||
|       <DropdownMenuTrigger as-child> | ||||
|         <Button | ||||
|           variant="ghost" | ||||
|           class="w-8 h-8 p-0" | ||||
|           v-if="!CONVERSATION_DEFAULT_STATUSES_LIST.includes(props.status.name)" | ||||
|         > | ||||
|           <span class="sr-only">Open menu</span> | ||||
|           <MoreHorizontal class="w-4 h-4" /> | ||||
|         </Button> | ||||
|         <div v-else class="w-8 h-8 p-0 invisible"></div> | ||||
|       </DropdownMenuTrigger> | ||||
|       <DropdownMenuContent> | ||||
|         <DialogTrigger as-child> | ||||
|   | ||||
| @@ -1,7 +1,11 @@ | ||||
| <template> | ||||
|   <CommandDialog :open="open" @update:open="handleOpenChange" class="z-[51]"> | ||||
|   <CommandDialog | ||||
|     :open="open" | ||||
|     @update:open="handleOpenChange" | ||||
|     class="z-[51] !min-w-[50vw] !min-h-[60vh]" | ||||
|   > | ||||
|     <CommandInput placeholder="Type a command or search..." @keydown="onInputKeydown" /> | ||||
|     <CommandList class="!min-h-[400px]"> | ||||
|     <CommandList class="!min-h-[60vh] !min-w-[50vw]"> | ||||
|       <CommandEmpty> | ||||
|         <p class="text-muted-foreground">No command available</p> | ||||
|       </CommandEmpty> | ||||
| @@ -10,7 +14,7 @@ | ||||
|       <CommandGroup | ||||
|         heading="Conversations" | ||||
|         value="conversations" | ||||
|         v-if="nestedCommand === null && conversationStore.current" | ||||
|         v-if="nestedCommand === null && conversationStore.hasConversationOpen" | ||||
|       > | ||||
|         <CommandItem value="conv-snooze" @select="setNestedCommand('snooze')"> Snooze </CommandItem> | ||||
|         <CommandItem value="conv-resolve" @select="resolveConversation"> Resolve </CommandItem> | ||||
| @@ -32,12 +36,12 @@ | ||||
|       </CommandGroup> | ||||
|  | ||||
|       <!-- Macros --> | ||||
|       <!-- TODO move to a separate component --> | ||||
|       <div v-if="nestedCommand === 'apply-macro'" class="bg-background"> | ||||
|         <CommandGroup heading="Apply macro" class="pb-2"> | ||||
|           <div class="min-h-[400px] overflow-auto"> | ||||
|             <div class="grid grid-cols-12 gap-3"> | ||||
|               <div class="col-span-4 border-r border-border/30 pr-2"> | ||||
|               <!-- Left Column: Macro List (30%) --> | ||||
|               <div class="col-span-4 pr-2 border-r"> | ||||
|                 <CommandItem | ||||
|                   v-for="(macro, index) in macroStore.macroOptions" | ||||
|                   :key="macro.value" | ||||
| @@ -45,25 +49,29 @@ | ||||
|                   :data-index="index" | ||||
|                   @select="handleApplyMacro(macro)" | ||||
|                   class="px-3 py-2 rounded-md cursor-pointer transition-all duration-200 hover:bg-primary/10 hover:text-primary" | ||||
|                   :class="{ 'bg-primary/5 text-primary': selectedMacroIndex === index }" | ||||
|                 > | ||||
|                   <div class="flex items-center space-x-2 justify-start"> | ||||
|                     <Zap :size="14" class="text-primary" /> | ||||
|                     <span class="text-sm overflow">{{ macro.label }}</span> | ||||
|                   <div class="flex items-center gap-2"> | ||||
|                     <Zap size="14" class="text-primary shrink-0" /> | ||||
|                     <span class="text-sm truncate w-full break-words whitespace-normal">{{ | ||||
|                       macro.label | ||||
|                     }}</span> | ||||
|                   </div> | ||||
|                 </CommandItem> | ||||
|               </div> | ||||
|  | ||||
|               <!-- Right Column: Macro Details (70%) --> | ||||
|               <div class="col-span-8 pl-2"> | ||||
|                 <div class="space-y-3 text-xs"> | ||||
|                   <!-- Reply Preview --> | ||||
|                   <div v-if="replyContent" class="space-y-1"> | ||||
|                     <p class="text-xs font-semibold text-primary">Reply Preview</p> | ||||
|                     <div | ||||
|                       class="w-full min-h-200 p-2 bg-muted/50 rounded-md overflow-auto shadow-sm" | ||||
|                       v-html="replyContent" | ||||
|                       class="w-full min-h-200 p-2 bg-muted/50 rounded-md overflow-auto shadow-sm native-html" | ||||
|                       v-dompurify-html="replyContent" | ||||
|                     /> | ||||
|                   </div> | ||||
|  | ||||
|                   <!-- Actions --> | ||||
|                   <div v-if="otherActions.length > 0" class="space-y-1"> | ||||
|                     <p class="text-xs font-semibold text-primary">Actions</p> | ||||
|                     <div class="space-y-1.5 max-w-sm"> | ||||
| @@ -105,6 +113,8 @@ | ||||
|                       </div> | ||||
|                     </div> | ||||
|                   </div> | ||||
|  | ||||
|                   <!-- Empty State --> | ||||
|                   <div | ||||
|                     v-if="!replyContent && otherActions.length === 0" | ||||
|                     class="flex items-center justify-center h-20" | ||||
| @@ -122,7 +132,6 @@ | ||||
|     </CommandList> | ||||
|  | ||||
|     <!-- Navigation --> | ||||
|     <!-- TODO: Move to a separate component --> | ||||
|     <div class="mt-2 px-4 py-2 text-xs text-gray-500 flex space-x-4"> | ||||
|       <span><kbd>Enter</kbd> select</span> | ||||
|       <span><kbd>↑</kbd>/<kbd>↓</kbd> navigate</span> | ||||
| @@ -132,7 +141,6 @@ | ||||
|   </CommandDialog> | ||||
|  | ||||
|   <!-- Date Picker for Custom Snooze --> | ||||
|   <!-- TODO: Move to a separate component --> | ||||
|   <Dialog :open="showDatePicker" @update:open="closeDatePicker"> | ||||
|     <DialogContent class="sm:max-w-[425px]"> | ||||
|       <DialogHeader> | ||||
| @@ -219,7 +227,9 @@ watch([Meta_K, Ctrl_K], ([mac, win]) => { | ||||
| const highlightedMacro = ref(null) | ||||
|  | ||||
| function handleApplyMacro(macro) { | ||||
|   conversationStore.setMacro(macro) | ||||
|   // Create a deep copy. | ||||
|   const plainMacro = JSON.parse(JSON.stringify(macro)) | ||||
|   conversationStore.setMacro(plainMacro) | ||||
|   handleOpenChange() | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -38,7 +38,7 @@ | ||||
|     <div class="flex flex-col flex-grow overflow-hidden"> | ||||
|       <MessageList class="flex-1 overflow-y-auto" /> | ||||
|       <div class="sticky bottom-0"> | ||||
|         <ReplyBox class="h-max" /> | ||||
|         <ReplyBox /> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| <template> | ||||
|   <div class="max-h-[600px] overflow-y-auto"> | ||||
|   <div class="editor-wrapper h-full overflow-y-auto"> | ||||
|     <BubbleMenu | ||||
|       :editor="editor" | ||||
|       :tippy-options="{ duration: 100 }" | ||||
| @@ -7,7 +7,7 @@ | ||||
|       class="bg-white p-1 box will-change-transform" | ||||
|     > | ||||
|       <div class="flex space-x-1 items-center"> | ||||
|         <DropdownMenu> | ||||
|         <DropdownMenu v-if="aiPrompts.length > 0"> | ||||
|           <DropdownMenuTrigger> | ||||
|             <Button size="sm" variant="ghost" class="flex items-center justify-center"> | ||||
|               <span class="flex items-center"> | ||||
| @@ -30,7 +30,7 @@ | ||||
|         <Button | ||||
|           size="sm" | ||||
|           variant="ghost" | ||||
|           @click="isBold = !isBold" | ||||
|           @click.prevent="isBold = !isBold" | ||||
|           :active="isBold" | ||||
|           :class="{ 'bg-gray-200': isBold }" | ||||
|         > | ||||
| @@ -39,22 +39,39 @@ | ||||
|         <Button | ||||
|           size="sm" | ||||
|           variant="ghost" | ||||
|           @click="isItalic = !isItalic" | ||||
|           @click.prevent="isItalic = !isItalic" | ||||
|           :active="isItalic" | ||||
|           :class="{ 'bg-gray-200': isItalic }" | ||||
|         > | ||||
|           <Italic size="14" /> | ||||
|         </Button> | ||||
|         <Button | ||||
|           size="sm" | ||||
|           variant="ghost" | ||||
|           @click.prevent="toggleBulletList" | ||||
|           :class="{ 'bg-gray-200': editor?.isActive('bulletList') }" | ||||
|         > | ||||
|           <List size="14" /> | ||||
|         </Button> | ||||
|  | ||||
|         <Button | ||||
|           size="sm" | ||||
|           variant="ghost" | ||||
|           @click.prevent="toggleOrderedList" | ||||
|           :class="{ 'bg-gray-200': editor?.isActive('orderedList') }" | ||||
|         > | ||||
|           <ListOrdered size="14" /> | ||||
|         </Button> | ||||
|       </div> | ||||
|     </BubbleMenu> | ||||
|     <EditorContent :editor="editor" /> | ||||
|     <EditorContent :editor="editor" class="native-html" /> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script setup> | ||||
| import { ref, watch, watchEffect, onUnmounted } from 'vue' | ||||
| import { useEditor, EditorContent, BubbleMenu } from '@tiptap/vue-3' | ||||
| import { ChevronDown, Bold, Italic, Bot } from 'lucide-vue-next' | ||||
| import { ChevronDown, Bold, Italic, Bot, List, ListOrdered } from 'lucide-vue-next' | ||||
| import { Button } from '@/components/ui/button' | ||||
| import { | ||||
|   DropdownMenu, | ||||
| @@ -95,28 +112,7 @@ const getSelectionText = (from, to, doc) => doc.textBetween(from, to) | ||||
| const editorConfig = { | ||||
|   extensions: [ | ||||
|     // Lists are unstyled in tailwind, so need to add classes to them. | ||||
|     StarterKit.configure({ | ||||
|       bulletList: { | ||||
|         HTMLAttributes: { | ||||
|           class: 'list-disc ml-6 my-2' | ||||
|         } | ||||
|       }, | ||||
|       orderedList: { | ||||
|         HTMLAttributes: { | ||||
|           class: 'list-decimal ml-6 my-2' | ||||
|         } | ||||
|       }, | ||||
|       listItem: { | ||||
|         HTMLAttributes: { | ||||
|           class: 'pl-1' | ||||
|         } | ||||
|       }, | ||||
|       heading: { | ||||
|         HTMLAttributes: { | ||||
|           class: 'text-xl font-bold mt-4 mb-2' | ||||
|         } | ||||
|       } | ||||
|     }), | ||||
|     StarterKit.configure(), | ||||
|     Image.configure({ HTMLAttributes: { class: 'inline-image' } }), | ||||
|     Placeholder.configure({ placeholder: () => props.placeholder }), | ||||
|     Link | ||||
| @@ -179,13 +175,20 @@ watchEffect(() => { | ||||
|  | ||||
| watch( | ||||
|   () => props.contentToSet, | ||||
|   (newContent) => { | ||||
|     if (newContent === '') { | ||||
|       editor.value?.commands.clearContent() | ||||
|     } else { | ||||
|       editor.value?.commands.setContent(newContent, true) | ||||
|   (newContentData) => { | ||||
|     if (!newContentData) return | ||||
|     try { | ||||
|       const parsedData = JSON.parse(newContentData) | ||||
|       const content = parsedData.content | ||||
|       if (content === '') { | ||||
|         editor.value?.commands.clearContent() | ||||
|       } else { | ||||
|         editor.value?.commands.setContent(content, true) | ||||
|       } | ||||
|       editor.value?.commands.focus() | ||||
|     } catch (e) { | ||||
|       console.error('Error parsing content data', e) | ||||
|     } | ||||
|     editor.value?.commands.focus() | ||||
|   } | ||||
| ) | ||||
|  | ||||
| @@ -231,6 +234,18 @@ watch( | ||||
| onUnmounted(() => { | ||||
|   editor.value?.destroy() | ||||
| }) | ||||
|  | ||||
| const toggleBulletList = () => { | ||||
|   if (editor.value) { | ||||
|     editor.value.chain().focus().toggleBulletList().run() | ||||
|   } | ||||
| } | ||||
|  | ||||
| const toggleOrderedList = () => { | ||||
|   if (editor.value) { | ||||
|     editor.value.chain().focus().toggleOrderedList().run() | ||||
|   } | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style lang="scss"> | ||||
| @@ -243,22 +258,26 @@ onUnmounted(() => { | ||||
|   height: 0; | ||||
| } | ||||
|  | ||||
| // Editor height | ||||
| .ProseMirror { | ||||
|   min-height: 80px !important; | ||||
|   max-height: 60% !important; | ||||
|   overflow-y: scroll !important; | ||||
| // Ensure the parent div has a proper height | ||||
| .editor-wrapper div[aria-expanded='false'] { | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   height: 100%; | ||||
| } | ||||
|  | ||||
| .fullscreen-tiptap-editor { | ||||
|   @apply p-0; | ||||
|   .ProseMirror { | ||||
|     min-height: 600px !important; | ||||
|     width: 90%; | ||||
|     scrollbar-width: none; | ||||
|   } | ||||
| // Ensure the editor content has a proper height and breaks words | ||||
| .tiptap.ProseMirror { | ||||
|   flex: 1; | ||||
|   min-height: 70px; | ||||
|   overflow-y: auto; | ||||
|   word-wrap: break-word !important; | ||||
|   overflow-wrap: break-word !important; | ||||
|   word-break: break-word; | ||||
|   white-space: pre-wrap; | ||||
|   max-width: 100%; | ||||
| } | ||||
|  | ||||
| // Anchor tag styling | ||||
| .tiptap { | ||||
|   a { | ||||
|     color: #0066cc; | ||||
|   | ||||
							
								
								
									
										345
									
								
								frontend/src/features/conversation/CreateConversation.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										345
									
								
								frontend/src/features/conversation/CreateConversation.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,345 @@ | ||||
| <template> | ||||
|   <Dialog :open="dialogOpen" @update:open="dialogOpen = false"> | ||||
|     <DialogContent class="max-w-5xl w-full h-[90vh] flex flex-col"> | ||||
|       <DialogHeader> | ||||
|         <DialogTitle>New Conversation</DialogTitle> | ||||
|       </DialogHeader> | ||||
|       <form @submit="createConversation" class="flex flex-col flex-1 overflow-hidden"> | ||||
|         <div class="flex-1 space-y-4 pr-1 overflow-y-auto pb-2"> | ||||
|           <FormField name="contact_email"> | ||||
|             <FormItem class="relative"> | ||||
|               <FormLabel>Email</FormLabel> | ||||
|               <FormControl> | ||||
|                 <Input | ||||
|                   type="email" | ||||
|                   placeholder="Search contact by email or type new email" | ||||
|                   v-model="emailQuery" | ||||
|                   @input="handleSearchContacts" | ||||
|                   autocomplete="off" | ||||
|                 /> | ||||
|               </FormControl> | ||||
|               <FormMessage /> | ||||
|  | ||||
|               <ul | ||||
|                 v-if="searchResults.length" | ||||
|                 class="border rounded p-2 max-h-60 overflow-y-auto absolute bg-white w-full z-50 shadow-lg" | ||||
|               > | ||||
|                 <li | ||||
|                   v-for="contact in searchResults" | ||||
|                   :key="contact.email" | ||||
|                   @click="selectContact(contact)" | ||||
|                   class="cursor-pointer p-2 hover:bg-gray-100 rounded" | ||||
|                 > | ||||
|                   {{ contact.first_name }} {{ contact.last_name }} ({{ contact.email }}) | ||||
|                 </li> | ||||
|               </ul> | ||||
|             </FormItem> | ||||
|           </FormField> | ||||
|  | ||||
|           <FormField v-slot="{ componentField }" name="first_name"> | ||||
|             <FormItem> | ||||
|               <FormLabel>First Name</FormLabel> | ||||
|               <FormControl> | ||||
|                 <Input type="text" placeholder="First Name" v-bind="componentField" required /> | ||||
|               </FormControl> | ||||
|               <FormMessage /> | ||||
|             </FormItem> | ||||
|           </FormField> | ||||
|  | ||||
|           <FormField v-slot="{ componentField }" name="last_name"> | ||||
|             <FormItem> | ||||
|               <FormLabel>Last Name</FormLabel> | ||||
|               <FormControl> | ||||
|                 <Input type="text" placeholder="Last Name" v-bind="componentField" required /> | ||||
|               </FormControl> | ||||
|               <FormMessage /> | ||||
|             </FormItem> | ||||
|           </FormField> | ||||
|  | ||||
|           <FormField v-slot="{ componentField }" name="subject"> | ||||
|             <FormItem> | ||||
|               <FormLabel>Subject</FormLabel> | ||||
|               <FormControl> | ||||
|                 <Input type="text" placeholder="Subject" v-bind="componentField" required /> | ||||
|               </FormControl> | ||||
|               <FormMessage /> | ||||
|             </FormItem> | ||||
|           </FormField> | ||||
|  | ||||
|           <FormField v-slot="{ componentField }" name="inbox_id"> | ||||
|             <FormItem> | ||||
|               <FormLabel>Inbox</FormLabel> | ||||
|               <FormControl> | ||||
|                 <Select v-bind="componentField"> | ||||
|                   <SelectTrigger> | ||||
|                     <SelectValue placeholder="Select an inbox" /> | ||||
|                   </SelectTrigger> | ||||
|                   <SelectContent> | ||||
|                     <SelectGroup> | ||||
|                       <SelectItem | ||||
|                         v-for="option in inboxStore.options" | ||||
|                         :key="option.value" | ||||
|                         :value="option.value" | ||||
|                       > | ||||
|                         {{ option.label }} | ||||
|                       </SelectItem> | ||||
|                     </SelectGroup> | ||||
|                   </SelectContent> | ||||
|                 </Select> | ||||
|               </FormControl> | ||||
|               <FormMessage /> | ||||
|             </FormItem> | ||||
|           </FormField> | ||||
|  | ||||
|           <!-- Set assigned team --> | ||||
|           <FormField v-slot="{ componentField }" name="team_id"> | ||||
|             <FormItem> | ||||
|               <FormLabel>Assign team (optional)</FormLabel> | ||||
|               <FormControl> | ||||
|                 <ComboBox | ||||
|                   v-bind="componentField" | ||||
|                   :items="[{ value: 'none', label: 'None' }, ...teamStore.options]" | ||||
|                   placeholder="Search team" | ||||
|                   defaultLabel="Assign team" | ||||
|                 > | ||||
|                   <template #item="{ item }"> | ||||
|                     <div class="flex items-center gap-3 py-2"> | ||||
|                       <div class="w-7 h-7 flex items-center justify-center"> | ||||
|                         <span v-if="item.emoji">{{ item.emoji }}</span> | ||||
|                         <div | ||||
|                           v-else | ||||
|                           class="text-primary bg-muted rounded-full w-7 h-7 flex items-center justify-center" | ||||
|                         > | ||||
|                           <Users size="14" /> | ||||
|                         </div> | ||||
|                       </div> | ||||
|                       <span class="text-sm">{{ item.label }}</span> | ||||
|                     </div> | ||||
|                   </template> | ||||
|  | ||||
|                   <template #selected="{ selected }"> | ||||
|                     <div class="flex items-center gap-3" v-if="selected"> | ||||
|                       <div class="w-7 h-7 flex items-center justify-center"> | ||||
|                         {{ selected?.emoji }} | ||||
|                       </div> | ||||
|                       <span class="text-sm">{{ selected?.label || 'Select team' }}</span> | ||||
|                     </div> | ||||
|                   </template> | ||||
|                 </ComboBox> | ||||
|               </FormControl> | ||||
|               <FormMessage /> | ||||
|             </FormItem> | ||||
|           </FormField> | ||||
|  | ||||
|           <!-- Set assigned agent --> | ||||
|           <FormField v-slot="{ componentField }" name="agent_id"> | ||||
|             <FormItem> | ||||
|               <FormLabel>Assign agent (optional)</FormLabel> | ||||
|               <FormControl> | ||||
|                 <ComboBox | ||||
|                   v-bind="componentField" | ||||
|                   :items="[{ value: 'none', label: 'None' }, ...uStore.options]" | ||||
|                   placeholder="Search agent" | ||||
|                   defaultLabel="Assign agent" | ||||
|                 > | ||||
|                   <template #item="{ item }"> | ||||
|                     <div class="flex items-center gap-3 py-2"> | ||||
|                       <Avatar class="w-8 h-8"> | ||||
|                         <AvatarImage | ||||
|                           :src="item.value === 'none' ? '/default-avatar.png' : item.avatar_url" | ||||
|                           :alt="item.value === 'none' ? 'N' : item.label.slice(0, 2)" | ||||
|                         /> | ||||
|                         <AvatarFallback> | ||||
|                           {{ item.value === 'none' ? 'N' : item.label.slice(0, 2).toUpperCase() }} | ||||
|                         </AvatarFallback> | ||||
|                       </Avatar> | ||||
|                       <span class="text-sm">{{ item.label }}</span> | ||||
|                     </div> | ||||
|                   </template> | ||||
|  | ||||
|                   <template #selected="{ selected }"> | ||||
|                     <div class="flex items-center gap-3"> | ||||
|                       <Avatar class="w-7 h-7" v-if="selected"> | ||||
|                         <AvatarImage | ||||
|                           :src=" | ||||
|                             selected?.value === 'none' | ||||
|                               ? '/default-avatar.png' | ||||
|                               : selected?.avatar_url | ||||
|                           " | ||||
|                           :alt="selected?.value === 'none' ? 'N' : selected?.label?.slice(0, 2)" | ||||
|                         /> | ||||
|                         <AvatarFallback> | ||||
|                           {{ | ||||
|                             selected?.value === 'none' | ||||
|                               ? 'N' | ||||
|                               : selected?.label?.slice(0, 2)?.toUpperCase() | ||||
|                           }} | ||||
|                         </AvatarFallback> | ||||
|                       </Avatar> | ||||
|                       <span class="text-sm">{{ selected?.label || 'Assign agent' }}</span> | ||||
|                     </div> | ||||
|                   </template> | ||||
|                 </ComboBox> | ||||
|               </FormControl> | ||||
|               <FormMessage /> | ||||
|             </FormItem> | ||||
|           </FormField> | ||||
|  | ||||
|           <FormField | ||||
|             v-slot="{ componentField }" | ||||
|             name="content" | ||||
|             class="flex-1 min-h-0 flex flex-col" | ||||
|           > | ||||
|             <FormItem class="flex flex-col flex-1"> | ||||
|               <FormLabel>Message</FormLabel> | ||||
|               <FormControl class="flex-1 min-h-0 flex flex-col"> | ||||
|                 <div class="flex-1 min-h-0 flex flex-col"> | ||||
|                   <Editor | ||||
|                     v-model:htmlContent="componentField.modelValue" | ||||
|                     @update:htmlContent="(value) => componentField.onChange(value)" | ||||
|                     :placeholder="'Shift + Enter to add new line'" | ||||
|                     class="w-full flex-1 overflow-y-auto p-2 min-h-[200px] box" | ||||
|                   /> | ||||
|                 </div> | ||||
|               </FormControl> | ||||
|               <FormMessage /> | ||||
|             </FormItem> | ||||
|           </FormField> | ||||
|         </div> | ||||
|  | ||||
|         <DialogFooter class="mt-4 pt-2 border-t shrink-0"> | ||||
|           <Button type="submit" :disabled="loading" :isLoading="loading"> Submit </Button> | ||||
|         </DialogFooter> | ||||
|       </form> | ||||
|     </DialogContent> | ||||
|   </Dialog> | ||||
| </template> | ||||
|  | ||||
| <script setup> | ||||
| import { | ||||
|   Dialog, | ||||
|   DialogContent, | ||||
|   DialogHeader, | ||||
|   DialogTitle, | ||||
|   DialogFooter | ||||
| } from '@/components/ui/dialog' | ||||
| import { Button } from '@/components/ui/button' | ||||
| import { Input } from '@/components/ui/input' | ||||
| import { useForm } from 'vee-validate' | ||||
| import { toTypedSchema } from '@vee-validate/zod' | ||||
| import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' | ||||
| import { z } from 'zod' | ||||
| import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' | ||||
| import { ref, defineModel, watch } from 'vue' | ||||
| import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' | ||||
| import { useEmitter } from '@/composables/useEmitter' | ||||
| import ComboBox from '@/components/ui/combobox/ComboBox.vue' | ||||
| import { handleHTTPError } from '@/utils/http' | ||||
| import { useInboxStore } from '@/stores/inbox' | ||||
| import { useUsersStore } from '@/stores/users' | ||||
| import { useTeamStore } from '@/stores/team' | ||||
| import { | ||||
|   Select, | ||||
|   SelectContent, | ||||
|   SelectGroup, | ||||
|   SelectItem, | ||||
|   SelectTrigger, | ||||
|   SelectValue | ||||
| } from '@/components/ui/select' | ||||
| import Editor from '@/features/conversation/ConversationTextEditor.vue' | ||||
| import api from '@/api' | ||||
|  | ||||
| const dialogOpen = defineModel({ | ||||
|   required: false, | ||||
|   default: () => false | ||||
| }) | ||||
|  | ||||
| const inboxStore = useInboxStore() | ||||
| const uStore = useUsersStore() | ||||
| const teamStore = useTeamStore() | ||||
| const emitter = useEmitter() | ||||
| const loading = ref(false) | ||||
| const searchResults = ref([]) | ||||
| const emailQuery = ref('') | ||||
| let timeoutId = null | ||||
|  | ||||
| const formSchema = z.object({ | ||||
|   subject: z.string().min(3, 'Subject must be at least 3 characters'), | ||||
|   content: z.string().min(1, 'Message cannot be empty'), | ||||
|   inbox_id: z.any().refine((val) => inboxStore.options.some((option) => option.value === val), { | ||||
|     message: 'Inbox is required' | ||||
|   }), | ||||
|   team_id: z.any().optional(), | ||||
|   agent_id: z.any().optional(), | ||||
|   contact_email: z.string().email('Invalid email address'), | ||||
|   first_name: z.string().min(1, 'First name is required'), | ||||
|   last_name: z.string().min(1, 'Last name is required') | ||||
| }) | ||||
|  | ||||
| const form = useForm({ | ||||
|   validationSchema: toTypedSchema(formSchema), | ||||
|   initialValues: { | ||||
|     inbox_id: null, | ||||
|     team_id: null, | ||||
|     agent_id: null, | ||||
|     subject: '', | ||||
|     content: '', | ||||
|     contact_email: '', | ||||
|     first_name: '', | ||||
|     last_name: '' | ||||
|   } | ||||
| }) | ||||
|  | ||||
| watch(emailQuery, (newVal) => { | ||||
|   form.setFieldValue('contact_email', newVal) | ||||
| }) | ||||
|  | ||||
| const handleSearchContacts = async () => { | ||||
|   clearTimeout(timeoutId) | ||||
|   timeoutId = setTimeout(async () => { | ||||
|     const query = emailQuery.value.trim() | ||||
|  | ||||
|     if (query.length < 3) { | ||||
|       searchResults.value.splice(0) | ||||
|       return | ||||
|     } | ||||
|  | ||||
|     try { | ||||
|       const resp = await api.searchContacts({ query }) | ||||
|       searchResults.value = [...resp.data.data] | ||||
|     } catch (error) { | ||||
|       emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { | ||||
|         title: 'Error', | ||||
|         variant: 'destructive', | ||||
|         description: handleHTTPError(error).message | ||||
|       }) | ||||
|       searchResults.value.splice(0) | ||||
|     } | ||||
|   }, 300) | ||||
| } | ||||
|  | ||||
| const selectContact = (contact) => { | ||||
|   emailQuery.value = contact.email | ||||
|   form.setFieldValue('first_name', contact.first_name) | ||||
|   form.setFieldValue('last_name', contact.last_name || '') | ||||
|   searchResults.value.splice(0) | ||||
| } | ||||
|  | ||||
| const createConversation = form.handleSubmit(async (values) => { | ||||
|   loading.value = true | ||||
|   try { | ||||
|     await api.createConversation(values) | ||||
|     dialogOpen.value = false | ||||
|     form.resetForm() | ||||
|     emailQuery.value = '' | ||||
|   } catch (error) { | ||||
|     emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { | ||||
|       title: 'Error', | ||||
|       variant: 'destructive', | ||||
|       description: handleHTTPError(error).message | ||||
|     }) | ||||
|   } finally { | ||||
|     loading.value = false | ||||
|   } | ||||
| }) | ||||
| </script> | ||||
| @@ -1,16 +1,16 @@ | ||||
| <template> | ||||
|   <div class="flex flex-wrap px-2 py-1"> | ||||
|   <div class="flex flex-wrap"> | ||||
|     <div class="flex flex-wrap gap-2"> | ||||
|       <div | ||||
|         v-for="action in actions" | ||||
|         :key="action.type" | ||||
|         class="flex items-center bg-white border border-gray-200 rounded shadow-sm transition-all duration-300 ease-in-out hover:shadow-md group" | ||||
|         class="flex items-center bg-white border border-gray-200 rounded shadow-sm transition-all duration-300 ease-in-out hover:shadow-md group gap-2 py-1" | ||||
|       > | ||||
|         <div class="flex items-center space-x-2 px-3 py-2"> | ||||
|         <div class="flex items-center space-x-2 px-2"> | ||||
|           <component | ||||
|             :is="getIcon(action.type)" | ||||
|             size="16" | ||||
|             class="text-primary group-hover:text-primary" | ||||
|             class="text-gray-500 text-primary group-hover:text-primary" | ||||
|           /> | ||||
|           <Tooltip> | ||||
|             <TooltipTrigger as-child> | ||||
| @@ -27,7 +27,7 @@ | ||||
|         </div> | ||||
|         <button | ||||
|           @click.stop="onRemove(action)" | ||||
|           class="p-2 text-gray-400 hover:text-red-500 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50 rounded transition-colors duration-300 ease-in-out" | ||||
|           class="pr-2 text-gray-400 hover:text-red-500 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50 rounded transition-colors duration-300 ease-in-out" | ||||
|           title="Remove action" | ||||
|         > | ||||
|           <X size="14" /> | ||||
|   | ||||
| @@ -1,330 +1,202 @@ | ||||
| <template> | ||||
|   <Dialog :open="openAIKeyPrompt" @update:open="openAIKeyPrompt = false"> | ||||
|     <DialogContent class="sm:max-w-lg"> | ||||
|       <DialogHeader class="space-y-2"> | ||||
|         <DialogTitle>Enter OpenAI API Key</DialogTitle> | ||||
|         <DialogDescription> | ||||
|           OpenAI API key is not set or invalid. Please enter a valid API key to use AI features. | ||||
|         </DialogDescription> | ||||
|       </DialogHeader> | ||||
|       <Form v-slot="{ handleSubmit }" as="" keep-values :validation-schema="formSchema"> | ||||
|         <form id="apiKeyForm" @submit="handleSubmit($event, updateProvider)"> | ||||
|           <FormField v-slot="{ componentField }" name="apiKey"> | ||||
|             <FormItem> | ||||
|               <FormLabel>API Key</FormLabel> | ||||
|               <FormControl> | ||||
|                 <Input type="text" placeholder="Enter your API key" v-bind="componentField" /> | ||||
|               </FormControl> | ||||
|               <FormMessage /> | ||||
|             </FormItem> | ||||
|           </FormField> | ||||
|         </form> | ||||
|         <DialogFooter> | ||||
|           <Button | ||||
|             type="submit" | ||||
|             form="apiKeyForm" | ||||
|             :is-loading="isOpenAIKeyUpdating" | ||||
|             :disabled="isOpenAIKeyUpdating" | ||||
|           > | ||||
|             Save | ||||
|           </Button> | ||||
|         </DialogFooter> | ||||
|       </Form> | ||||
|     </DialogContent> | ||||
|   </Dialog> | ||||
|  | ||||
|   <div class="text-foreground bg-background"> | ||||
|     <!-- Fullscreen editor --> | ||||
|     <Dialog :open="isEditorFullscreen" @update:open="isEditorFullscreen = false"> | ||||
|       <DialogContent | ||||
|         class="max-w-[70%] max-h-[70%] h-[90%] w-full bg-card text-card-foreground px-4 py-4" | ||||
|         class="max-w-[70%] max-h-[70%] h-[70%] bg-card text-card-foreground p-4 flex flex-col" | ||||
|         @escapeKeyDown="isEditorFullscreen = false" | ||||
|         hide-close-button="true" | ||||
|         :hide-close-button="true" | ||||
|       > | ||||
|         <div v-if="isEditorFullscreen" class="h-full flex flex-col"> | ||||
|           <!-- Message type toggle --> | ||||
|           <div class="flex justify-between items-center border-b border-border pb-4"> | ||||
|             <Tabs v-model="messageType" class="rounded-lg"> | ||||
|               <TabsList class="bg-muted p-1 rounded-lg"> | ||||
|                 <TabsTrigger | ||||
|                   value="reply" | ||||
|                   class="px-3 py-1 rounded-lg transition-colors duration-200" | ||||
|                   :class="{ 'bg-background text-foreground': messageType === 'reply' }" | ||||
|                 > | ||||
|                   Reply | ||||
|                 </TabsTrigger> | ||||
|                 <TabsTrigger | ||||
|                   value="private_note" | ||||
|                   class="px-3 py-1 rounded-lg transition-colors duration-200" | ||||
|                   :class="{ 'bg-background text-foreground': messageType === 'private_note' }" | ||||
|                 > | ||||
|                   Private note | ||||
|                 </TabsTrigger> | ||||
|               </TabsList> | ||||
|             </Tabs> | ||||
|             <span | ||||
|               class="text-muted-foreground hover:text-foreground transition-colors duration-200 cursor-pointer" | ||||
|               variant="ghost" | ||||
|               @click="isEditorFullscreen = false" | ||||
|             > | ||||
|               <Minimize2 size="18" /> | ||||
|             </span> | ||||
|           </div> | ||||
|  | ||||
|           <!-- CC and BCC fields --> | ||||
|           <div class="space-y-3 p-4 border-b border-border" v-if="messageType === 'reply'"> | ||||
|             <div class="flex items-center space-x-2"> | ||||
|               <label class="w-12 text-sm font-medium text-muted-foreground">CC:</label> | ||||
|               <Input | ||||
|                 type="text" | ||||
|                 placeholder="Email addresses separated by comma" | ||||
|                 v-model="cc" | ||||
|                 class="flex-grow px-3 py-2 text-sm border rounded-md focus:ring-2 focus:ring-ring" | ||||
|                 @blur="validateEmails('cc')" | ||||
|               /> | ||||
|               <Button | ||||
|                 size="sm" | ||||
|                 @click="hideBcc" | ||||
|                 class="text-sm bg-secondary text-secondary-foreground hover:bg-secondary/80" | ||||
|               > | ||||
|                 {{ showBcc ? 'Remove BCC' : 'BCC' }} | ||||
|               </Button> | ||||
|             </div> | ||||
|             <div v-if="showBcc" class="flex items-center space-x-2"> | ||||
|               <label class="w-12 text-sm font-medium text-muted-foreground">BCC:</label> | ||||
|               <Input | ||||
|                 type="text" | ||||
|                 placeholder="Email addresses separated by comma" | ||||
|                 v-model="bcc" | ||||
|                 class="flex-grow px-3 py-2 text-sm border rounded-md focus:ring-2 focus:ring-ring" | ||||
|                 @blur="validateEmails('bcc')" | ||||
|               /> | ||||
|             </div> | ||||
|           </div> | ||||
|  | ||||
|           <div | ||||
|             v-if="emailErrors.length > 0" | ||||
|             class="mb-4 px-2 py-1 bg-destructive/10 border border-destructive text-destructive rounded" | ||||
|           > | ||||
|             <p v-for="error in emailErrors" :key="error" class="text-sm">{{ error }}</p> | ||||
|           </div> | ||||
|  | ||||
|           <!-- Main Editor --> | ||||
|           <div class="flex-grow overflow-y-auto p-2"> | ||||
|             <Editor | ||||
|               v-model:selectedText="selectedText" | ||||
|               v-model:isBold="isBold" | ||||
|               v-model:isItalic="isItalic" | ||||
|               v-model:htmlContent="htmlContent" | ||||
|               v-model:textContent="textContent" | ||||
|               :placeholder="editorPlaceholder" | ||||
|               :aiPrompts="aiPrompts" | ||||
|               @aiPromptSelected="handleAiPromptSelected" | ||||
|               :contentToSet="contentToSet" | ||||
|               @send="handleSend" | ||||
|               v-model:cursorPosition="cursorPosition" | ||||
|               :clearContent="clearEditorContent" | ||||
|               :setInlineImage="setInlineImage" | ||||
|               :insertContent="insertContent" | ||||
|               class="h-full" | ||||
|             /> | ||||
|           </div> | ||||
|  | ||||
|           <!-- Macro preview --> | ||||
|           <MacroActionsPreview | ||||
|             v-if="conversationStore.conversation?.macro?.actions?.length > 0" | ||||
|             :actions="conversationStore.conversation.macro.actions" | ||||
|             :onRemove="conversationStore.removeMacroAction" | ||||
|             class="mt-4" | ||||
|           /> | ||||
|  | ||||
|           <!-- Attachments preview --> | ||||
|           <AttachmentsPreview | ||||
|             :attachments="attachments" | ||||
|             :uploadingFiles="uploadingFiles" | ||||
|             :onDelete="handleOnFileDelete" | ||||
|             v-if="attachments.length > 0 || uploadingFiles.length > 0" | ||||
|             class="mt-4" | ||||
|           /> | ||||
|  | ||||
|           <!-- Bottom menu bar --> | ||||
|           <ReplyBoxBottomMenuBar | ||||
|             class="mt-4  pt-4" | ||||
|             :handleFileUpload="handleFileUpload" | ||||
|             :handleInlineImageUpload="handleInlineImageUpload" | ||||
|             :isBold="isBold" | ||||
|             :isItalic="isItalic" | ||||
|             :isSending="isSending" | ||||
|             @toggleBold="toggleBold" | ||||
|             @toggleItalic="toggleItalic" | ||||
|             :enableSend="enableSend" | ||||
|             :handleSend="handleSend" | ||||
|             @emojiSelect="handleEmojiSelect" | ||||
|           /> | ||||
|         </div> | ||||
|         <ReplyBoxContent | ||||
|           v-if="isEditorFullscreen" | ||||
|           :isFullscreen="true" | ||||
|           :aiPrompts="aiPrompts" | ||||
|           :isSending="isSending" | ||||
|           :uploadingFiles="uploadingFiles" | ||||
|           :clearEditorContent="clearEditorContent" | ||||
|           :htmlContent="htmlContent" | ||||
|           :textContent="textContent" | ||||
|           :selectedText="selectedText" | ||||
|           :isBold="isBold" | ||||
|           :isItalic="isItalic" | ||||
|           :cursorPosition="cursorPosition" | ||||
|           :contentToSet="contentToSet" | ||||
|           :cc="cc" | ||||
|           :bcc="bcc" | ||||
|           :emailErrors="emailErrors" | ||||
|           :messageType="messageType" | ||||
|           :showBcc="showBcc" | ||||
|           @update:htmlContent="htmlContent = $event" | ||||
|           @update:textContent="textContent = $event" | ||||
|           @update:selectedText="selectedText = $event" | ||||
|           @update:isBold="isBold = $event" | ||||
|           @update:isItalic="isItalic = $event" | ||||
|           @update:cursorPosition="cursorPosition = $event" | ||||
|           @toggleFullscreen="isEditorFullscreen = false" | ||||
|           @update:messageType="messageType = $event" | ||||
|           @update:cc="cc = $event" | ||||
|           @update:bcc="bcc = $event" | ||||
|           @update:showBcc="showBcc = $event" | ||||
|           @updateEmailErrors="emailErrors = $event" | ||||
|           @send="processSend" | ||||
|           @fileUpload="handleFileUpload" | ||||
|           @inlineImageUpload="handleInlineImageUpload" | ||||
|           @fileDelete="handleOnFileDelete" | ||||
|           @aiPromptSelected="handleAiPromptSelected" | ||||
|           class="h-full flex-grow" | ||||
|         /> | ||||
|       </DialogContent> | ||||
|     </Dialog> | ||||
|  | ||||
|     <!-- Main Editor non-fullscreen --> | ||||
|     <div class="bg-card text-card-foreground box px-2 pt-2 m-2"> | ||||
|       <div v-if="!isEditorFullscreen" class=""> | ||||
|         <!-- Message type toggle --> | ||||
|         <div class="flex justify-between items-center mb-4"> | ||||
|           <Tabs v-model="messageType" class="rounded-lg"> | ||||
|             <TabsList class="bg-muted p-1 rounded-lg"> | ||||
|               <TabsTrigger | ||||
|                 value="reply" | ||||
|                 class="px-3 py-1 rounded-lg transition-colors duration-200" | ||||
|                 :class="{ 'bg-background text-foreground': messageType === 'reply' }" | ||||
|               > | ||||
|                 Reply | ||||
|               </TabsTrigger> | ||||
|               <TabsTrigger | ||||
|                 value="private_note" | ||||
|                 class="px-3 py-1 rounded-lg transition-colors duration-200" | ||||
|                 :class="{ 'bg-background text-foreground': messageType === 'private_note' }" | ||||
|               > | ||||
|                 Private note | ||||
|               </TabsTrigger> | ||||
|             </TabsList> | ||||
|           </Tabs> | ||||
|           <span | ||||
|             class="text-muted-foreground hover:text-foreground transition-colors duration-200 cursor-pointer mr-2" | ||||
|             variant="ghost" | ||||
|             @click="isEditorFullscreen = true" | ||||
|           > | ||||
|             <Maximize2 size="15" /> | ||||
|           </span> | ||||
|         </div> | ||||
|  | ||||
|         <div class="space-y-3 mb-4" v-if="messageType === 'reply'"> | ||||
|           <div class="flex items-center space-x-2"> | ||||
|             <label class="w-12 text-sm font-medium text-muted-foreground">CC:</label> | ||||
|             <Input | ||||
|               type="text" | ||||
|               placeholder="Email addresses separated by comma" | ||||
|               v-model="cc" | ||||
|               class="flex-grow px-3 py-2 text-sm border rounded-md focus:ring-2 focus:ring-ring" | ||||
|               @blur="validateEmails('cc')" | ||||
|             /> | ||||
|             <Button | ||||
|               size="sm" | ||||
|               @click="hideBcc" | ||||
|               class="text-sm bg-secondary text-secondary-foreground hover:bg-secondary/80" | ||||
|             > | ||||
|               {{ showBcc ? 'Remove BCC' : 'BCC' }} | ||||
|             </Button> | ||||
|           </div> | ||||
|           <div v-if="showBcc" class="flex items-center space-x-2"> | ||||
|             <label class="w-12 text-sm font-medium text-muted-foreground">BCC:</label> | ||||
|             <Input | ||||
|               type="text" | ||||
|               placeholder="Email addresses separated by comma" | ||||
|               v-model="bcc" | ||||
|               class="flex-grow px-3 py-2 text-sm border rounded-md focus:ring-2 focus:ring-ring" | ||||
|               @blur="validateEmails('bcc')" | ||||
|             /> | ||||
|           </div> | ||||
|         </div> | ||||
|  | ||||
|         <div | ||||
|           v-if="emailErrors.length > 0" | ||||
|           class="mb-4 px-2 py-1 bg-destructive/10 border border-destructive text-destructive rounded" | ||||
|         > | ||||
|           <p v-for="error in emailErrors" :key="error" class="text-sm">{{ error }}</p> | ||||
|         </div> | ||||
|  | ||||
|         <!-- Main Editor --> | ||||
|         <Editor | ||||
|           v-model:selectedText="selectedText" | ||||
|           v-model:isBold="isBold" | ||||
|           v-model:isItalic="isItalic" | ||||
|           v-model:htmlContent="htmlContent" | ||||
|           v-model:textContent="textContent" | ||||
|           :placeholder="editorPlaceholder" | ||||
|           :aiPrompts="aiPrompts" | ||||
|           @aiPromptSelected="handleAiPromptSelected" | ||||
|           :contentToSet="contentToSet" | ||||
|           @send="handleSend" | ||||
|           v-model:cursorPosition="cursorPosition" | ||||
|           :clearContent="clearEditorContent" | ||||
|           :setInlineImage="setInlineImage" | ||||
|           :insertContent="insertContent" | ||||
|         /> | ||||
|  | ||||
|         <!-- Macro preview --> | ||||
|         <MacroActionsPreview | ||||
|           v-if="conversationStore.conversation?.macro?.actions?.length > 0" | ||||
|           :actions="conversationStore.conversation.macro.actions" | ||||
|           :onRemove="conversationStore.removeMacroAction" | ||||
|         /> | ||||
|  | ||||
|         <!-- Attachments preview --> | ||||
|         <AttachmentsPreview | ||||
|           :attachments="attachments" | ||||
|           :uploadingFiles="uploadingFiles" | ||||
|           :onDelete="handleOnFileDelete" | ||||
|           v-if="attachments.length > 0 || uploadingFiles.length > 0" | ||||
|           class="mt-4" | ||||
|         /> | ||||
|  | ||||
|         <!-- Bottom menu bar --> | ||||
|         <ReplyBoxBottomMenuBar | ||||
|           class="mt-1" | ||||
|           :handleFileUpload="handleFileUpload" | ||||
|           :handleInlineImageUpload="handleInlineImageUpload" | ||||
|           :isBold="isBold" | ||||
|           :isItalic="isItalic" | ||||
|           :isSending="isSending" | ||||
|           @toggleBold="toggleBold" | ||||
|           @toggleItalic="toggleItalic" | ||||
|           :enableSend="enableSend" | ||||
|           :handleSend="handleSend" | ||||
|           @emojiSelect="handleEmojiSelect" | ||||
|         /> | ||||
|       </div> | ||||
|     <div | ||||
|       class="bg-card text-card-foreground box m-2 px-2 pt-2 flex flex-col" | ||||
|       v-if="!isEditorFullscreen" | ||||
|     > | ||||
|       <ReplyBoxContent | ||||
|         :isFullscreen="false" | ||||
|         :aiPrompts="aiPrompts" | ||||
|         :isSending="isSending" | ||||
|         :uploadingFiles="uploadingFiles" | ||||
|         :clearEditorContent="clearEditorContent" | ||||
|         :htmlContent="htmlContent" | ||||
|         :textContent="textContent" | ||||
|         :selectedText="selectedText" | ||||
|         :isBold="isBold" | ||||
|         :isItalic="isItalic" | ||||
|         :cursorPosition="cursorPosition" | ||||
|         :contentToSet="contentToSet" | ||||
|         :cc="cc" | ||||
|         :bcc="bcc" | ||||
|         :emailErrors="emailErrors" | ||||
|         :messageType="messageType" | ||||
|         :showBcc="showBcc" | ||||
|         @update:htmlContent="htmlContent = $event" | ||||
|         @update:textContent="textContent = $event" | ||||
|         @update:selectedText="selectedText = $event" | ||||
|         @update:isBold="isBold = $event" | ||||
|         @update:isItalic="isItalic = $event" | ||||
|         @update:cursorPosition="cursorPosition = $event" | ||||
|         @toggleFullscreen="isEditorFullscreen = true" | ||||
|         @update:messageType="messageType = $event" | ||||
|         @update:cc="cc = $event" | ||||
|         @update:bcc="bcc = $event" | ||||
|         @update:showBcc="showBcc = $event" | ||||
|         @updateEmailErrors="emailErrors = $event" | ||||
|         @send="processSend" | ||||
|         @fileUpload="handleFileUpload" | ||||
|         @inlineImageUpload="handleInlineImageUpload" | ||||
|         @fileDelete="handleOnFileDelete" | ||||
|         @aiPromptSelected="handleAiPromptSelected" | ||||
|       /> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script setup> | ||||
| import { ref, onMounted, computed, nextTick, watch } from 'vue' | ||||
| import { ref, onMounted, nextTick, watch, computed } from 'vue' | ||||
| import { transformImageSrcToCID } from '@/utils/strings' | ||||
| import { handleHTTPError } from '@/utils/http' | ||||
| import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' | ||||
| import { Maximize2, Minimize2 } from 'lucide-vue-next' | ||||
| import { useUserStore } from '@/stores/user' | ||||
| import api from '@/api' | ||||
|  | ||||
| import Editor from './ConversationTextEditor.vue' | ||||
| import { useConversationStore } from '@/stores/conversation' | ||||
| import { Input } from '@/components/ui/input' | ||||
| import { Button } from '@/components/ui/button' | ||||
| import { Dialog, DialogContent } from '@/components/ui/dialog' | ||||
| import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' | ||||
| import { | ||||
|   Dialog, | ||||
|   DialogContent, | ||||
|   DialogDescription, | ||||
|   DialogFooter, | ||||
|   DialogHeader, | ||||
|   DialogTitle | ||||
| } from '@/components/ui/dialog' | ||||
| import { Input } from '@/components/ui/input' | ||||
| import { useEmitter } from '@/composables/useEmitter' | ||||
| import AttachmentsPreview from '@/features/conversation/message/attachment/AttachmentsPreview.vue' | ||||
| import MacroActionsPreview from '@/features/conversation/MacroActionsPreview.vue' | ||||
| import ReplyBoxBottomMenuBar from '@/features/conversation/ReplyBoxMenuBar.vue' | ||||
| import ReplyBoxContent from '@/features/conversation/ReplyBoxContent.vue' | ||||
| import { | ||||
|   Form, | ||||
|   FormField, | ||||
|   FormItem, | ||||
|   FormLabel, | ||||
|   FormControl, | ||||
|   FormMessage | ||||
| } from '@/components/ui/form' | ||||
| import { toTypedSchema } from '@vee-validate/zod' | ||||
| import * as z from 'zod' | ||||
|  | ||||
| const formSchema = toTypedSchema( | ||||
|   z.object({ | ||||
|     apiKey: z.string().min(1, 'API key is required') | ||||
|   }) | ||||
| ) | ||||
|  | ||||
| const conversationStore = useConversationStore() | ||||
| const emitter = useEmitter() | ||||
| const insertContent = ref(null) | ||||
| const setInlineImage = ref(null) | ||||
| const userStore = useUserStore() | ||||
| const openAIKeyPrompt = ref(false) | ||||
| const isOpenAIKeyUpdating = ref(false) | ||||
|  | ||||
| // Shared state between the two editor components. | ||||
| const clearEditorContent = ref(false) | ||||
| const isEditorFullscreen = ref(false) | ||||
| const isSending = ref(false) | ||||
| const cursorPosition = ref(0) | ||||
| const selectedText = ref('') | ||||
| const htmlContent = ref('') | ||||
| const textContent = ref('') | ||||
| const contentToSet = ref('') | ||||
| const isBold = ref(false) | ||||
| const isItalic = ref(false) | ||||
| const messageType = ref('reply') | ||||
| const showBcc = ref(false) | ||||
| const cc = ref('') | ||||
| const bcc = ref('') | ||||
| const showBcc = ref(false) | ||||
| const emailErrors = ref([]) | ||||
| const aiPrompts = ref([]) | ||||
| const uploadingFiles = ref([]) | ||||
| const editorPlaceholder = 'Press Enter to add a new line; Press Ctrl + Enter to send.' | ||||
| const htmlContent = ref('') | ||||
| const textContent = ref('') | ||||
| const selectedText = ref('') | ||||
| const isBold = ref(false) | ||||
| const isItalic = ref(false) | ||||
| const cursorPosition = ref(0) | ||||
| const contentToSet = ref('') | ||||
|  | ||||
| onMounted(async () => { | ||||
|   await fetchAiPrompts() | ||||
| }) | ||||
|  | ||||
| const hideBcc = () => { | ||||
|   showBcc.value = !showBcc.value | ||||
| } | ||||
|  | ||||
| watch( | ||||
|   () => conversationStore.currentCC, | ||||
|   (newVal) => { | ||||
|     cc.value = newVal?.join(', ') || '' | ||||
|   }, | ||||
|   { deep: true, immediate: true } | ||||
| ) | ||||
|  | ||||
| watch( | ||||
|   () => conversationStore.currentBCC, | ||||
|   (newVal) => { | ||||
|     const newBcc = newVal?.join(', ') || '' | ||||
|     bcc.value = newBcc | ||||
|     if (newBcc.length == 0) { | ||||
|       showBcc.value = false | ||||
|     } else { | ||||
|       showBcc.value = true | ||||
|     } | ||||
|   }, | ||||
|   { deep: true, immediate: true } | ||||
| ) | ||||
|  | ||||
| /** | ||||
|  * Fetches AI prompts from the server. | ||||
|  */ | ||||
| const fetchAiPrompts = async () => { | ||||
|   try { | ||||
|     const resp = await api.getAiPrompts() | ||||
| @@ -338,14 +210,27 @@ const fetchAiPrompts = async () => { | ||||
|   } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Handles the AI prompt selection event. | ||||
|  * Sends the selected prompt key and the current text content to the server for completion. | ||||
|  * Sets the response as the new content in the editor. | ||||
|  * @param {String} key - The key of the selected AI prompt | ||||
|  */ | ||||
| const handleAiPromptSelected = async (key) => { | ||||
|   try { | ||||
|     const resp = await api.aiCompletion({ | ||||
|       prompt_key: key, | ||||
|       content: selectedText.value | ||||
|       content: textContent.value | ||||
|     }) | ||||
|     contentToSet.value = JSON.stringify({ | ||||
|       content: resp.data.data.replace(/\n/g, '<br>'), | ||||
|       timestamp: Date.now() | ||||
|     }) | ||||
|     contentToSet.value = resp.data.data.replace(/\n/g, '<br>') | ||||
|   } catch (error) { | ||||
|     // Check if user needs to enter OpenAI API key and has permission to do so. | ||||
|     if (error.response?.status === 400 && userStore.can('ai:manage')) { | ||||
|       openAIKeyPrompt.value = true | ||||
|     } | ||||
|     emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { | ||||
|       title: 'Error', | ||||
|       variant: 'destructive', | ||||
| @@ -354,33 +239,35 @@ const handleAiPromptSelected = async (key) => { | ||||
|   } | ||||
| } | ||||
|  | ||||
| const toggleBold = () => { | ||||
|   isBold.value = !isBold.value | ||||
| /** | ||||
|  * updateProvider updates the OpenAI API key. | ||||
|  * @param {Object} values - The form values containing the API key | ||||
|  */ | ||||
| const updateProvider = async (values) => { | ||||
|   try { | ||||
|     isOpenAIKeyUpdating.value = true | ||||
|     await api.updateAIProvider({ api_key: values.apiKey, provider: 'openai' }) | ||||
|     openAIKeyPrompt.value = false | ||||
|     emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { | ||||
|       title: 'Success', | ||||
|       description: 'API key saved successfully.' | ||||
|     }) | ||||
|   } catch (error) { | ||||
|     emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { | ||||
|       title: 'Error', | ||||
|       variant: 'destructive', | ||||
|       description: handleHTTPError(error).message | ||||
|     }) | ||||
|   } finally { | ||||
|     isOpenAIKeyUpdating.value = false | ||||
|   } | ||||
| } | ||||
|  | ||||
| const toggleItalic = () => { | ||||
|   isItalic.value = !isItalic.value | ||||
| } | ||||
|  | ||||
| const attachments = computed(() => { | ||||
|   return conversationStore.conversation.mediaFiles.filter( | ||||
|     (upload) => upload.disposition === 'attachment' | ||||
|   ) | ||||
| }) | ||||
|  | ||||
| const enableSend = computed(() => { | ||||
|   return ( | ||||
|     (textContent.value.trim().length > 0 || | ||||
|       conversationStore.conversation?.macro?.actions?.length > 0) && | ||||
|     emailErrors.value.length === 0 && | ||||
|     !uploadingFiles.value.length | ||||
|   ) | ||||
| }) | ||||
|  | ||||
| const hasTextContent = computed(() => { | ||||
|   return textContent.value.trim().length > 0 | ||||
| }) | ||||
|  | ||||
| /** | ||||
|  * Handles the file upload process when files are selected. | ||||
|  * Uploads each file to the server and adds them to the conversation's mediaFiles. | ||||
|  * @param {Event} event - The file input change event containing selected files | ||||
|  */ | ||||
| const handleFileUpload = (event) => { | ||||
|   const files = Array.from(event.target.files) | ||||
|   uploadingFiles.value = files | ||||
| @@ -407,6 +294,7 @@ const handleFileUpload = (event) => { | ||||
|   } | ||||
| } | ||||
|  | ||||
| // Inline image upload is not supported yet. | ||||
| const handleInlineImageUpload = (event) => { | ||||
|   for (const file of event.target.files) { | ||||
|     api | ||||
| @@ -416,12 +304,13 @@ const handleInlineImageUpload = (event) => { | ||||
|         linked_model: 'messages' | ||||
|       }) | ||||
|       .then((resp) => { | ||||
|         setInlineImage.value = { | ||||
|         const imageData = { | ||||
|           src: resp.data.data.url, | ||||
|           alt: resp.data.data.filename, | ||||
|           title: resp.data.data.uuid | ||||
|         } | ||||
|         conversationStore.conversation.mediaFiles.push(resp.data.data) | ||||
|         return imageData | ||||
|       }) | ||||
|       .catch((error) => { | ||||
|         emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { | ||||
| @@ -433,44 +322,24 @@ const handleInlineImageUpload = (event) => { | ||||
|   } | ||||
| } | ||||
|  | ||||
| const validateEmails = (field) => { | ||||
|   const emails = field === 'cc' ? cc.value : bcc.value | ||||
|   const emailList = emails | ||||
|     .split(',') | ||||
|     .map((e) => e.trim()) | ||||
|     .filter((e) => e !== '') | ||||
|   const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ | ||||
|   const invalidEmails = emailList.filter((email) => !emailRegex.test(email)) | ||||
|  | ||||
|   // Remove any existing errors for this field | ||||
|   emailErrors.value = emailErrors.value.filter( | ||||
|     (error) => !error.startsWith(`Invalid email(s) in ${field.toUpperCase()}`) | ||||
|   ) | ||||
|  | ||||
|   // Add new error if there are invalid emails | ||||
|   if (invalidEmails.length > 0) { | ||||
|     emailErrors.value.push( | ||||
|       `Invalid email(s) in ${field.toUpperCase()}: ${invalidEmails.join(', ')}` | ||||
|     ) | ||||
|   } | ||||
| } | ||||
|  | ||||
| const handleSend = async () => { | ||||
|   if (emailErrors.value.length > 0) { | ||||
|     emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { | ||||
|       title: 'Error', | ||||
|       variant: 'destructive', | ||||
|       description: 'Please correct the email errors before sending.' | ||||
|     }) | ||||
|     return | ||||
|   } | ||||
| /** | ||||
|  * Returns true if the editor has text content. | ||||
|  */ | ||||
| const hasTextContent = computed(() => { | ||||
|   return textContent.value.trim().length > 0 | ||||
| }) | ||||
|  | ||||
| /** | ||||
|  * Processes the send action. | ||||
|  */ | ||||
| const processSend = async () => { | ||||
|   let hasAPIErrored = false | ||||
|   isEditorFullscreen.value = false | ||||
|   try { | ||||
|     isSending.value = true | ||||
|  | ||||
|     // Send message if there is text content in the editor. | ||||
|     if (hasTextContent.value) { | ||||
|     if (hasTextContent.value > 0) { | ||||
|       // Replace inline image url with cid. | ||||
|       const message = transformImageSrcToCID(htmlContent.value) | ||||
|  | ||||
| @@ -498,7 +367,7 @@ const handleSend = async () => { | ||||
|           .split(',') | ||||
|           .map((email) => email.trim()) | ||||
|           .filter((email) => email), | ||||
|         bcc: showBcc.value | ||||
|         bcc: bcc.value | ||||
|           ? bcc.value | ||||
|               .split(',') | ||||
|               .map((email) => email.trim()) | ||||
| @@ -507,57 +376,101 @@ const handleSend = async () => { | ||||
|       }) | ||||
|     } | ||||
|  | ||||
|     // Apply macro if it exists. | ||||
|     // Apply macro actions if any. | ||||
|     // For macros errors just show toast and clear the editor, as most likely it's the permission error. | ||||
|     if (conversationStore.conversation?.macro?.actions?.length > 0) { | ||||
|       await api.applyMacro( | ||||
|         conversationStore.current.uuid, | ||||
|         conversationStore.conversation.macro.id, | ||||
|         conversationStore.conversation.macro.actions | ||||
|       ) | ||||
|       try { | ||||
|         await api.applyMacro( | ||||
|           conversationStore.current.uuid, | ||||
|           conversationStore.conversation.macro.id, | ||||
|           conversationStore.conversation.macro.actions | ||||
|         ) | ||||
|       } catch (error) { | ||||
|         emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { | ||||
|           title: 'Error', | ||||
|           variant: 'destructive', | ||||
|           description: handleHTTPError(error).message | ||||
|         }) | ||||
|       } | ||||
|     } | ||||
|  | ||||
|   } catch (error) { | ||||
|     hasAPIErrored = true | ||||
|     emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { | ||||
|       title: 'Error', | ||||
|       variant: 'destructive', | ||||
|       description: handleHTTPError(error).message | ||||
|     }) | ||||
|   } finally { | ||||
|     // If API has NOT errored clear state. | ||||
|     if (hasAPIErrored === false) { | ||||
|       // Clear editor. | ||||
|       clearEditorContent.value = true | ||||
|  | ||||
|       // Clear macro. | ||||
|       conversationStore.resetMacro() | ||||
|  | ||||
|       // Clear media files. | ||||
|       conversationStore.resetMediaFiles() | ||||
|  | ||||
|       // Clear any email errors. | ||||
|       emailErrors.value = [] | ||||
|  | ||||
|       nextTick(() => { | ||||
|         clearEditorContent.value = false | ||||
|       }) | ||||
|     } | ||||
|     isSending.value = false | ||||
|     clearEditorContent.value = true | ||||
|     conversationStore.resetMacro() | ||||
|     conversationStore.resetMediaFiles() | ||||
|     emailErrors.value = [] | ||||
|     nextTick(() => { | ||||
|       clearEditorContent.value = false | ||||
|     }) | ||||
|   } | ||||
|   // Update assignee last seen timestamp. | ||||
|   api.updateAssigneeLastSeen(conversationStore.current.uuid) | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Handles the file delete event. | ||||
|  * Removes the file from the conversation's mediaFiles. | ||||
|  * @param {String} uuid - The UUID of the file to delete | ||||
|  */ | ||||
| const handleOnFileDelete = (uuid) => { | ||||
|   conversationStore.conversation.mediaFiles = conversationStore.conversation.mediaFiles.filter( | ||||
|     (item) => item.uuid !== uuid | ||||
|   ) | ||||
| } | ||||
|  | ||||
| const handleEmojiSelect = (emoji) => { | ||||
|   insertContent.value = undefined | ||||
|   // Force reactivity so the user can select the same emoji multiple times | ||||
|   nextTick(() => (insertContent.value = emoji)) | ||||
| } | ||||
|  | ||||
| // Watch for changes in macro content and update editor content. | ||||
| /** | ||||
|  * Watches for changes in the conversation's macro id and update message content. | ||||
|  */ | ||||
| watch( | ||||
|   () => conversationStore.conversation.macro, | ||||
|   () => conversationStore.conversation.macro.id, | ||||
|   () => { | ||||
|     // hack: Quill editor adds <p><br></p> replace with <p></p> | ||||
|     if (conversationStore.conversation?.macro?.message_content) { | ||||
|       contentToSet.value = conversationStore.conversation.macro.message_content.replace( | ||||
|         /<p><br><\/p>/g, | ||||
|         '<p></p>' | ||||
|       ) | ||||
|     } | ||||
|     // Setting timestamp, so the same macro can be set again. | ||||
|     contentToSet.value = JSON.stringify({ | ||||
|       content: conversationStore.conversation.macro.message_content, | ||||
|       timestamp: Date.now() | ||||
|     }) | ||||
|   }, | ||||
|   { deep: true } | ||||
| ) | ||||
|  | ||||
| // Initialize cc and bcc from conversation store | ||||
| watch( | ||||
|   () => conversationStore.currentCC, | ||||
|   (newVal) => { | ||||
|     cc.value = newVal?.join(', ') || '' | ||||
|   }, | ||||
|   { deep: true, immediate: true } | ||||
| ) | ||||
|  | ||||
| watch( | ||||
|   () => conversationStore.currentBCC, | ||||
|   (newVal) => { | ||||
|     const newBcc = newVal?.join(', ') || '' | ||||
|     bcc.value = newBcc | ||||
|     // Only show BCC field if it has content | ||||
|     if (newBcc.length > 0) { | ||||
|       showBcc.value = true | ||||
|     } | ||||
|   }, | ||||
|   { deep: true, immediate: true } | ||||
| ) | ||||
| </script> | ||||
|   | ||||
							
								
								
									
										307
									
								
								frontend/src/features/conversation/ReplyBoxContent.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										307
									
								
								frontend/src/features/conversation/ReplyBoxContent.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,307 @@ | ||||
| <template> | ||||
|   <!-- Set fixed width only when not in fullscreen. --> | ||||
|   <div class="flex flex-col h-full" :class="{ 'max-h-[600px]': !isFullscreen }"> | ||||
|     <!-- Message type toggle --> | ||||
|     <div | ||||
|       class="flex justify-between items-center" | ||||
|       :class="{ 'mb-4': !isFullscreen, 'border-b border-border pb-4': isFullscreen }" | ||||
|     > | ||||
|       <Tabs v-model="messageType" class="rounded-lg"> | ||||
|         <TabsList class="bg-muted p-1 rounded-lg"> | ||||
|           <TabsTrigger | ||||
|             value="reply" | ||||
|             class="px-3 py-1 rounded-lg transition-colors duration-200" | ||||
|             :class="{ 'bg-background text-foreground': messageType === 'reply' }" | ||||
|           > | ||||
|             Reply | ||||
|           </TabsTrigger> | ||||
|           <TabsTrigger | ||||
|             value="private_note" | ||||
|             class="px-3 py-1 rounded-lg transition-colors duration-200" | ||||
|             :class="{ 'bg-background text-foreground': messageType === 'private_note' }" | ||||
|           > | ||||
|             Private note | ||||
|           </TabsTrigger> | ||||
|         </TabsList> | ||||
|       </Tabs> | ||||
|       <span | ||||
|         class="text-muted-foreground hover:text-foreground transition-colors duration-200 cursor-pointer" | ||||
|         variant="ghost" | ||||
|         @click="toggleFullscreen" | ||||
|       > | ||||
|         <component | ||||
|           :is="isFullscreen ? Minimize2 : Maximize2" | ||||
|           :size="isFullscreen ? '18' : '15'" | ||||
|           :class="{ 'mr-2': !isFullscreen }" | ||||
|         /> | ||||
|       </span> | ||||
|     </div> | ||||
|  | ||||
|     <!-- CC and BCC fields --> | ||||
|     <div | ||||
|       :class="['space-y-3', isFullscreen ? 'p-4 border-b border-border' : 'mb-4']" | ||||
|       v-if="messageType === 'reply'" | ||||
|     > | ||||
|       <div class="flex items-center space-x-2"> | ||||
|         <label class="w-12 text-sm font-medium text-muted-foreground">CC:</label> | ||||
|         <Input | ||||
|           type="text" | ||||
|           placeholder="Email addresses separated by comma" | ||||
|           v-model="cc" | ||||
|           class="flex-grow px-3 py-2 text-sm border rounded-md focus:ring-2 focus:ring-ring" | ||||
|           @blur="validateEmails('cc')" | ||||
|         /> | ||||
|         <Button | ||||
|           size="sm" | ||||
|           @click="toggleBcc" | ||||
|           class="text-sm bg-secondary text-secondary-foreground hover:bg-secondary/80" | ||||
|         > | ||||
|           {{ showBcc ? 'Remove BCC' : 'BCC' }} | ||||
|         </Button> | ||||
|       </div> | ||||
|       <div v-if="showBcc" class="flex items-center space-x-2"> | ||||
|         <label class="w-12 text-sm font-medium text-muted-foreground">BCC:</label> | ||||
|         <Input | ||||
|           type="text" | ||||
|           placeholder="Email addresses separated by comma" | ||||
|           v-model="bcc" | ||||
|           class="flex-grow px-3 py-2 text-sm border rounded-md focus:ring-2 focus:ring-ring" | ||||
|           @blur="validateEmails('bcc')" | ||||
|         /> | ||||
|       </div> | ||||
|     </div> | ||||
|  | ||||
|     <!-- CC and BCC field validation errors --> | ||||
|     <div | ||||
|       v-if="emailErrors.length > 0" | ||||
|       class="mb-4 px-2 py-1 bg-destructive/10 border border-destructive text-destructive rounded" | ||||
|     > | ||||
|       <p v-for="error in emailErrors" :key="error" class="text-sm">{{ error }}</p> | ||||
|     </div> | ||||
|  | ||||
|     <!-- Main tiptap editor --> | ||||
|     <div class="flex-grow flex flex-col overflow-hidden"> | ||||
|       <Editor | ||||
|         v-model:selectedText="selectedText" | ||||
|         v-model:isBold="isBold" | ||||
|         v-model:isItalic="isItalic" | ||||
|         v-model:htmlContent="htmlContent" | ||||
|         v-model:textContent="textContent" | ||||
|         v-model:cursorPosition="cursorPosition" | ||||
|         :placeholder="editorPlaceholder" | ||||
|         :aiPrompts="aiPrompts" | ||||
|         @aiPromptSelected="handleAiPromptSelected" | ||||
|         :contentToSet="contentToSet" | ||||
|         @send="handleSend" | ||||
|         :clearContent="clearEditorContent" | ||||
|         :setInlineImage="setInlineImage" | ||||
|         :insertContent="insertContent" | ||||
|       /> | ||||
|     </div> | ||||
|  | ||||
|     <!-- Macro preview --> | ||||
|     <MacroActionsPreview | ||||
|       v-if="conversationStore.conversation?.macro?.actions?.length > 0" | ||||
|       :actions="conversationStore.conversation.macro.actions" | ||||
|       :onRemove="conversationStore.removeMacroAction" | ||||
|       class="mt-2" | ||||
|     /> | ||||
|  | ||||
|     <!-- Attachments preview --> | ||||
|     <AttachmentsPreview | ||||
|       :attachments="attachments" | ||||
|       :uploadingFiles="uploadingFiles" | ||||
|       :onDelete="handleOnFileDelete" | ||||
|       v-if="attachments.length > 0 || uploadingFiles.length > 0" | ||||
|       class="mt-2" | ||||
|     /> | ||||
|  | ||||
|     <!-- Editor menu bar with send button --> | ||||
|     <ReplyBoxMenuBar | ||||
|       class="mt-1 shrink-0" | ||||
|       :handleFileUpload="handleFileUpload" | ||||
|       :handleInlineImageUpload="handleInlineImageUpload" | ||||
|       :isBold="isBold" | ||||
|       :isItalic="isItalic" | ||||
|       :isSending="isSending" | ||||
|       @toggleBold="toggleBold" | ||||
|       @toggleItalic="toggleItalic" | ||||
|       :enableSend="enableSend" | ||||
|       :handleSend="handleSend" | ||||
|       @emojiSelect="handleEmojiSelect" | ||||
|     /> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script setup> | ||||
| import { ref, computed, nextTick } from 'vue' | ||||
| import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' | ||||
| import { Maximize2, Minimize2 } from 'lucide-vue-next' | ||||
| import Editor from './ConversationTextEditor.vue' | ||||
| import { useConversationStore } from '@/stores/conversation' | ||||
| import { Input } from '@/components/ui/input' | ||||
| import { Button } from '@/components/ui/button' | ||||
| import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' | ||||
| import { useEmitter } from '@/composables/useEmitter' | ||||
| import AttachmentsPreview from '@/features/conversation/message/attachment/AttachmentsPreview.vue' | ||||
| import MacroActionsPreview from '@/features/conversation/MacroActionsPreview.vue' | ||||
| import ReplyBoxMenuBar from '@/features/conversation/ReplyBoxMenuBar.vue' | ||||
|  | ||||
| // Define models for two-way binding | ||||
| const messageType = defineModel('messageType', { default: 'reply' }) | ||||
| const cc = defineModel('cc', { default: '' }) | ||||
| const bcc = defineModel('bcc', { default: '' }) | ||||
| const showBcc = defineModel('showBcc', { default: false }) | ||||
| const emailErrors = defineModel('emailErrors', { default: () => [] }) | ||||
| const htmlContent = defineModel('htmlContent', { default: '' }) | ||||
| const textContent = defineModel('textContent', { default: '' }) | ||||
| const selectedText = defineModel('selectedText', { default: '' }) | ||||
| const isBold = defineModel('isBold', { default: false }) | ||||
| const isItalic = defineModel('isItalic', { default: false }) | ||||
| const cursorPosition = defineModel('cursorPosition', { default: 0 }) | ||||
|  | ||||
| const props = defineProps({ | ||||
|   isFullscreen: { | ||||
|     type: Boolean, | ||||
|     default: false | ||||
|   }, | ||||
|   aiPrompts: { | ||||
|     type: Array, | ||||
|     required: true | ||||
|   }, | ||||
|   isSending: { | ||||
|     type: Boolean, | ||||
|     required: true | ||||
|   }, | ||||
|   uploadingFiles: { | ||||
|     type: Array, | ||||
|     required: true | ||||
|   }, | ||||
|   clearEditorContent: { | ||||
|     type: Boolean, | ||||
|     required: true | ||||
|   }, | ||||
|   contentToSet: { | ||||
|     type: String, | ||||
|     default: null | ||||
|   } | ||||
| }) | ||||
|  | ||||
| const emit = defineEmits([ | ||||
|   'toggleFullscreen', | ||||
|   'send', | ||||
|   'fileUpload', | ||||
|   'inlineImageUpload', | ||||
|   'fileDelete', | ||||
|   'aiPromptSelected' | ||||
| ]) | ||||
|  | ||||
| const conversationStore = useConversationStore() | ||||
| const emitter = useEmitter() | ||||
|  | ||||
| const insertContent = ref(null) | ||||
| const setInlineImage = ref(null) | ||||
| const editorPlaceholder = | ||||
|   'Shift + Enter to add a new line. Cmd + Enter to send. Cmd + K to open command bar.' | ||||
|  | ||||
| const toggleBcc = async () => { | ||||
|   showBcc.value = !showBcc.value | ||||
|   await nextTick() | ||||
|   // If hiding BCC field, clear the content | ||||
|   if (!showBcc.value) { | ||||
|     bcc.value = '' | ||||
|   } | ||||
| } | ||||
|  | ||||
| const toggleFullscreen = () => { | ||||
|   emit('toggleFullscreen') | ||||
| } | ||||
|  | ||||
| const toggleBold = () => { | ||||
|   isBold.value = !isBold.value | ||||
| } | ||||
|  | ||||
| const toggleItalic = () => { | ||||
|   isItalic.value = !isItalic.value | ||||
| } | ||||
|  | ||||
| const attachments = computed(() => { | ||||
|   return conversationStore.conversation.mediaFiles.filter( | ||||
|     (upload) => upload.disposition === 'attachment' | ||||
|   ) | ||||
| }) | ||||
|  | ||||
| const enableSend = computed(() => { | ||||
|   return ( | ||||
|     (textContent.value.trim().length > 0 || | ||||
|       conversationStore.conversation?.macro?.actions?.length > 0) && | ||||
|     emailErrors.value.length === 0 && | ||||
|     !props.uploadingFiles.length | ||||
|   ) | ||||
| }) | ||||
|  | ||||
| /** | ||||
|  * Validate email addresses in the CC and BCC fields | ||||
|  * @param {string} field - 'cc' or 'bcc' | ||||
|  */ | ||||
| const validateEmails = (field) => { | ||||
|   const emails = field === 'cc' ? cc.value : bcc.value | ||||
|   const emailList = emails | ||||
|     .split(',') | ||||
|     .map((e) => e.trim()) | ||||
|     .filter((e) => e !== '') | ||||
|   const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ | ||||
|   const invalidEmails = emailList.filter((email) => !emailRegex.test(email)) | ||||
|  | ||||
|   // Remove any existing errors for this field | ||||
|   emailErrors.value = emailErrors.value.filter( | ||||
|     (error) => !error.startsWith(`Invalid email(s) in ${field.toUpperCase()}`) | ||||
|   ) | ||||
|  | ||||
|   // Add new error if there are invalid emails | ||||
|   if (invalidEmails.length > 0) { | ||||
|     emailErrors.value.push( | ||||
|       `Invalid email(s) in ${field.toUpperCase()}: ${invalidEmails.join(', ')}` | ||||
|     ) | ||||
|   } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Send the reply or private note | ||||
|  */ | ||||
| const handleSend = async () => { | ||||
|   validateEmails('cc') | ||||
|   validateEmails('bcc') | ||||
|   if (emailErrors.value.length > 0) { | ||||
|     emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { | ||||
|       title: 'Error', | ||||
|       variant: 'destructive', | ||||
|       description: 'Please correct the email errors before sending.' | ||||
|     }) | ||||
|     return | ||||
|   } | ||||
|   emit('send') | ||||
| } | ||||
|  | ||||
| const handleFileUpload = (event) => { | ||||
|   emit('fileUpload', event) | ||||
| } | ||||
|  | ||||
| const handleInlineImageUpload = (event) => { | ||||
|   emit('inlineImageUpload', event) | ||||
| } | ||||
|  | ||||
| const handleOnFileDelete = (uuid) => { | ||||
|   emit('fileDelete', uuid) | ||||
| } | ||||
|  | ||||
| const handleEmojiSelect = (emoji) => { | ||||
|   insertContent.value = undefined | ||||
|   // Force reactivity so the user can select the same emoji multiple times | ||||
|   nextTick(() => (insertContent.value = emoji)) | ||||
| } | ||||
|  | ||||
| const handleAiPromptSelected = (key) => { | ||||
|   emit('aiPromptSelected', key) | ||||
| } | ||||
| </script> | ||||
| @@ -35,7 +35,9 @@ | ||||
|         <Smile class="h-4 w-4" /> | ||||
|       </Toggle> | ||||
|     </div> | ||||
|     <Button class="h-8 w-6 px-8" @click="handleSend" :disabled="!enableSend" :isLoading="isSending">Send</Button> | ||||
|     <Button class="h-8 w-6 px-8" @click="handleSend" :disabled="!enableSend" :isLoading="isSending" | ||||
|       >Send</Button | ||||
|     > | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| @@ -52,11 +54,10 @@ const attachmentInput = ref(null) | ||||
| const inlineImageInput = ref(null) | ||||
| const isEmojiPickerVisible = ref(false) | ||||
| const emojiPickerRef = ref(null) | ||||
| const emit = defineEmits(['toggleBold', 'toggleItalic', 'emojiSelect']) | ||||
| const emit = defineEmits(['emojiSelect']) | ||||
|  | ||||
| // Using defineProps for props that don't need two-way binding | ||||
| defineProps({ | ||||
|   isBold: Boolean, | ||||
|   isItalic: Boolean, | ||||
|   isSending: Boolean, | ||||
|   enableSend: Boolean, | ||||
|   handleSend: Function, | ||||
| @@ -69,7 +70,11 @@ onClickOutside(emojiPickerRef, () => { | ||||
| }) | ||||
|  | ||||
| const triggerFileUpload = () => { | ||||
|   attachmentInput.value.click() | ||||
|   if (attachmentInput.value) { | ||||
|     // Clear the value to allow the same file to be uploaded again. | ||||
|     attachmentInput.value.value = '' | ||||
|     attachmentInput.value.click() | ||||
|   } | ||||
| } | ||||
|  | ||||
| const toggleEmojiPicker = () => { | ||||
|   | ||||
| @@ -8,7 +8,8 @@ | ||||
|  | ||||
|     <!-- Filters --> | ||||
|     <div class="bg-white p-2 flex justify-between items-center"> | ||||
|       <DropdownMenu> | ||||
|       <!-- Status dropdown-menu, hidden when a view is selected as views are pre-filtered --> | ||||
|       <DropdownMenu v-if="!route.params.viewID"> | ||||
|         <DropdownMenuTrigger asChild> | ||||
|           <Button variant="ghost" class="w-30"> | ||||
|             <div> | ||||
| @@ -28,6 +29,9 @@ | ||||
|           </DropdownMenuItem> | ||||
|         </DropdownMenuContent> | ||||
|       </DropdownMenu> | ||||
|       <div v-else></div> | ||||
|  | ||||
|       <!-- Sort dropdown-menu --> | ||||
|       <DropdownMenu> | ||||
|         <DropdownMenuTrigger asChild> | ||||
|           <Button variant="ghost" class="w-30"> | ||||
| @@ -124,7 +128,12 @@ | ||||
|           <Loader2 v-if="isLoading" class="mr-2 h-4 w-4 animate-spin" /> | ||||
|           {{ isLoading ? 'Loading...' : 'Load more' }} | ||||
|         </Button> | ||||
|         <p v-else class="text-sm text-gray-500">All conversations loaded</p> | ||||
|         <p | ||||
|           class="text-sm text-gray-500" | ||||
|           v-else-if="conversationStore.conversationsList.length > 10" | ||||
|         > | ||||
|           All conversations loaded | ||||
|         </p> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
|   | ||||
| @@ -39,7 +39,7 @@ | ||||
|  | ||||
|         <!-- Message preview and unread count --> | ||||
|         <div class="flex items-start justify-between gap-2"> | ||||
|           <div class="text-sm text-gray-600 flex items-center gap-1.5 flex-1"> | ||||
|           <div class="text-sm text-gray-600 flex items-center gap-1.5 flex-1 break-all"> | ||||
|             <Reply | ||||
|               class="text-green-600 flex-shrink-0" | ||||
|               size="15" | ||||
| @@ -57,16 +57,18 @@ | ||||
|  | ||||
|         <div class="flex items-center mt-2 space-x-2"> | ||||
|           <SlaBadge | ||||
|             v-if="conversation.first_response_due_at" | ||||
|             :dueAt="conversation.first_response_due_at" | ||||
|             :actualAt="conversation.first_reply_at" | ||||
|             :label="'FRD'" | ||||
|             :showSLAMet="false" | ||||
|             :showExtra="false" | ||||
|           /> | ||||
|           <SlaBadge | ||||
|             v-if="conversation.resolution_due_at" | ||||
|             :dueAt="conversation.resolution_due_at" | ||||
|             :actualAt="conversation.resolved_at" | ||||
|             :label="'RD'" | ||||
|             :showSLAMet="false" | ||||
|             :showExtra="false" | ||||
|           /> | ||||
|         </div> | ||||
|       </div> | ||||
|   | ||||
| @@ -19,7 +19,11 @@ | ||||
|         }" | ||||
|       > | ||||
|         <!-- Message Content --> | ||||
|         <div v-html="messageContent" :class="{ 'mb-3': message.attachments.length > 0 }"></div> | ||||
|         <div | ||||
|           v-dompurify-html="messageContent" | ||||
|           class="whitespace-pre-wrap break-words overflow-wrap-anywhere native-html"  | ||||
|           :class="{ 'mb-3': message.attachments.length > 0 }" | ||||
|         /> | ||||
|  | ||||
|         <!-- Attachments --> | ||||
|         <MessageAttachmentPreview :attachments="nonInlineAttachments" /> | ||||
| @@ -125,3 +129,9 @@ const retryMessage = (msg) => { | ||||
|   api.retryMessage(convStore.current.uuid, msg.uuid) | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style scoped> | ||||
| .overflow-wrap-anywhere { | ||||
|   overflow-wrap: anywhere; | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -29,7 +29,7 @@ | ||||
|         <Letter | ||||
|           :html="sanitizedMessageContent" | ||||
|           :allowedSchemas="['cid', 'https', 'http']" | ||||
|           class="mb-1" | ||||
|           class="mb-1 native-html" | ||||
|           :class="{ 'mb-3': message.attachments.length > 0 }" | ||||
|         /> | ||||
|  | ||||
|   | ||||
| @@ -22,16 +22,14 @@ | ||||
|  | ||||
|         <MessagesSkeleton :count="10" v-if="conversationStore.messages.loading" /> | ||||
|  | ||||
|         <TransitionGroup | ||||
|           v-else | ||||
|           enter-active-class="animate-slide-in" | ||||
|           tag="div" | ||||
|           class="space-y-4" | ||||
|         > | ||||
|         <TransitionGroup v-else enter-active-class="animate-slide-in" tag="div" class="space-y-4"> | ||||
|           <div | ||||
|             v-for="message in conversationStore.conversationMessages" | ||||
|             v-for="(message, index) in conversationStore.conversationMessages" | ||||
|             :key="message.uuid" | ||||
|             :class="message.type === 'activity' ? 'my-2' : 'my-4'" | ||||
|             :class="{ | ||||
|               'my-2': message.type === 'activity', | ||||
|               'pt-4': index === 0 | ||||
|             }" | ||||
|           > | ||||
|             <div v-if="!message.private"> | ||||
|               <ContactMessageBubble :message="message" v-if="message.type === 'incoming'" /> | ||||
| @@ -57,7 +55,7 @@ | ||||
|       leave-from-class="opacity-100 translate-y-0" | ||||
|       leave-to-class="opacity-0 translate-y-1" | ||||
|     > | ||||
|       <div v-show="!isAtBottom" class="absolute bottom-12 right-6 z-10"> | ||||
|       <div v-show="!isAtBottom" class="absolute bottom-5 right-6 z-10"> | ||||
|         <button | ||||
|           @click="handleScrollToBottom" | ||||
|           class="w-10 h-10 rounded-full flex items-center justify-center shadow-lg border bg-white text-primary transition-colors duration-200 hover:bg-gray-100" | ||||
|   | ||||
| @@ -7,11 +7,7 @@ | ||||
|         class="flex items-center bg-white border border-gray-200 rounded shadow-sm transition-all duration-300 ease-in-out hover:shadow-md group px-2 gap-2" | ||||
|       > | ||||
|         <div class="flex items-center space-x-1 py-1"> | ||||
|           <span v-if="attachment.loading" class="dot-loader"> | ||||
|             <span class="dot"></span> | ||||
|             <span class="dot"></span> | ||||
|             <span class="dot"></span> | ||||
|           </span> | ||||
|           <DotLoader v-if="attachment.loading"/> | ||||
|           <PaperclipIcon v-else size="16" class="text-gray-500 group-hover:text-primary" /> | ||||
|  | ||||
|           <Tooltip> | ||||
| @@ -48,6 +44,7 @@ | ||||
| import { computed } from 'vue' | ||||
| import { formatBytes } from '@/utils/file.js' | ||||
| import { X, Paperclip as PaperclipIcon } from 'lucide-vue-next' | ||||
| import { DotLoader } from '@/components/ui/loader' | ||||
| import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' | ||||
|  | ||||
| const props = defineProps({ | ||||
|   | ||||
| @@ -1,5 +0,0 @@ | ||||
| <template> | ||||
|     hi | ||||
| </template> | ||||
|  | ||||
| <script setup></script> | ||||
| @@ -27,8 +27,10 @@ | ||||
|     <div class="flex justify-start items-center space-x-2"> | ||||
|       <p class="font-medium">First reply at</p> | ||||
|       <SlaBadge | ||||
|         v-if="conversation.first_response_due_at" | ||||
|         :dueAt="conversation.first_response_due_at" | ||||
|         :actualAt="conversation.first_reply_at" | ||||
|         :key="conversation.uuid" | ||||
|       /> | ||||
|     </div> | ||||
|     <Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4" /> | ||||
| @@ -43,7 +45,12 @@ | ||||
|   <div class="flex flex-col gap-1 mb-5"> | ||||
|     <div class="flex justify-start items-center space-x-2"> | ||||
|       <p class="font-medium">Resolved at</p> | ||||
|       <SlaBadge :dueAt="conversation.resolution_due_at" :actualAt="conversation.resolved_at" /> | ||||
|       <SlaBadge  | ||||
|         v-if="conversation.resolution_due_at" | ||||
|         :dueAt="conversation.resolution_due_at" | ||||
|         :actualAt="conversation.resolved_at" | ||||
|         :key="conversation.uuid" | ||||
|       /> | ||||
|     </div> | ||||
|     <Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4" /> | ||||
|     <div v-else> | ||||
|   | ||||
| @@ -6,7 +6,7 @@ | ||||
|       collapsible | ||||
|       :default-value="['Actions', 'Information', 'Previous conversations']" | ||||
|     > | ||||
|       <AccordionItem value="Actions" class="border-0 mb-2 mb-2"> | ||||
|       <AccordionItem value="Actions" class="border-0 mb-2"> | ||||
|         <AccordionTrigger class="bg-muted px-4 py-3 text-sm font-medium rounded-lg mx-2"> | ||||
|           Actions | ||||
|         </AccordionTrigger> | ||||
|   | ||||
| @@ -1,25 +1,36 @@ | ||||
| <template> | ||||
|   <div class="flex flex-1 flex-col gap-x-5 box p-5 space-y-5 bg-white"> | ||||
|     <div class="flex items-center space-x-2"> | ||||
|       <p class="text-2xl">{{ title }}</p> | ||||
|       <p class="text-2xl flex items-center">{{ title }}</p> | ||||
|       <div class="bg-green-100/70 flex items-center space-x-2 px-1 rounded"> | ||||
|         <span class="blinking-dot"></span> | ||||
|         <p class="uppercase text-xs">Live</p> | ||||
|       </div> | ||||
|     </div> | ||||
|     <div class="flex justify-between pr-32"> | ||||
|       <div v-for="(value, key) in counts" :key="key" class="flex flex-col items-center gap-y-2"> | ||||
|       <div | ||||
|         v-for="(item, key) in filteredCounts" | ||||
|         :key="key" | ||||
|         class="flex flex-col items-center gap-y-2" | ||||
|       > | ||||
|         <span class="text-muted-foreground">{{ labels[key] }}</span> | ||||
|         <span class="text-2xl font-medium">{{ value }}</span> | ||||
|         <span class="text-2xl font-medium">{{ item }}</span> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script setup> | ||||
| defineProps({ | ||||
| import { computed } from 'vue' | ||||
|  | ||||
| const props = defineProps({ | ||||
|   counts: { type: Object, required: true }, | ||||
|   labels: { type: Object, required: true }, | ||||
|   title: { type: String, required: true } | ||||
| }) | ||||
|  | ||||
| // Filter out counts that don't have a label | ||||
| const filteredCounts = computed(() => { | ||||
|   return Object.fromEntries(Object.entries(props.counts).filter(([key]) => props.labels[key])) | ||||
| }) | ||||
| </script> | ||||
|   | ||||
| @@ -1,16 +0,0 @@ | ||||
| <template> | ||||
|     <div class="flex flex-col space-y-6" v-if="userStore.getFullName"> | ||||
|         <div> | ||||
|             <span class="font-medium text-xl space-y-1"> | ||||
|                 <p class="font-semibold text-2xl">Hi, {{ userStore.getFullName }}</p> | ||||
|                 <p class="text-muted-foreground text-lg">🌤️ {{ format(new Date(), 'EEEE, MMMM d, HH:mm a') }}</p> | ||||
|             </span> | ||||
|         </div> | ||||
|     </div> | ||||
| </template> | ||||
|  | ||||
| <script setup> | ||||
| import { format } from 'date-fns' | ||||
| import { useUserStore } from '@/stores/user' | ||||
| const userStore = useUserStore() | ||||
| </script> | ||||
| @@ -1,32 +1,33 @@ | ||||
| <template> | ||||
|   <div v-if="dueAt" class="flex justify-start items-center space-x-2"> | ||||
|     <TransitionGroup name="fade"> | ||||
|       <!-- Overdue--> | ||||
|       <span v-if="sla?.status === 'overdue'" key="overdue" class="sla-badge box sla-overdue"> | ||||
|         <AlertCircle size="10" class="text-red-800" /> | ||||
|         <span class="text-xs text-red-800">{{ label }} Overdue</span> | ||||
|     <!-- Overdue--> | ||||
|     <span v-if="sla?.status === 'overdue'" key="overdue" class="sla-badge box sla-overdue"> | ||||
|       <AlertCircle size="12" class="text-red-800" /> | ||||
|       <span class="sla-text text-red-800" | ||||
|         >{{ label }} Overdue | ||||
|         <span v-if="showExtra">by {{ sla.value }}</span> | ||||
|       </span> | ||||
|     </span> | ||||
|  | ||||
|       <!-- SLA Hit --> | ||||
|       <span | ||||
|         v-else-if="sla?.status === 'hit' && showSLAMet" | ||||
|         key="sla-hit" | ||||
|         class="sla-badge box sla-hit" | ||||
|       > | ||||
|         <CheckCircle size="10" /> | ||||
|         <span class="sla-text">{{ label }} SLA met</span> | ||||
|       </span> | ||||
|     <!-- SLA Hit --> | ||||
|     <span | ||||
|       v-else-if="sla?.status === 'hit' && showExtra" | ||||
|       key="sla-hit" | ||||
|       class="sla-badge box sla-hit" | ||||
|     > | ||||
|       <CheckCircle size="12" /> | ||||
|       <span class="sla-text">{{ label }} SLA met</span> | ||||
|     </span> | ||||
|  | ||||
|       <!-- Remaining --> | ||||
|       <span | ||||
|         v-else-if="sla?.status === 'remaining'" | ||||
|         key="remaining" | ||||
|         class="sla-badge box sla-remaining" | ||||
|       > | ||||
|         <Clock size="10" /> | ||||
|         <span class="sla-text">{{ label }} {{ sla.value }}</span> | ||||
|       </span> | ||||
|     </TransitionGroup> | ||||
|     <!-- Remaining --> | ||||
|     <span | ||||
|       v-else-if="sla?.status === 'remaining'" | ||||
|       key="remaining" | ||||
|       class="sla-badge box sla-remaining" | ||||
|     > | ||||
|       <Clock size="12" /> | ||||
|       <span class="sla-text">{{ label }} {{ sla.value }}</span> | ||||
|     </span> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| @@ -38,12 +39,16 @@ const props = defineProps({ | ||||
|   dueAt: String, | ||||
|   actualAt: String, | ||||
|   label: String, | ||||
|   showSLAMet: { | ||||
|   showExtra: { | ||||
|     type: Boolean, | ||||
|     default: true | ||||
|   } | ||||
| }) | ||||
| const { sla } = useSla(ref(props.dueAt), ref(props.actualAt)) | ||||
|  | ||||
| let sla = null | ||||
| if (props.dueAt) { | ||||
|   sla = useSla(ref(props.dueAt), ref(props.actualAt)) | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style scoped> | ||||
| @@ -62,4 +67,8 @@ const { sla } = useSla(ref(props.dueAt), ref(props.actualAt)) | ||||
| .sla-remaining { | ||||
|   @apply bg-yellow-100 text-yellow-800; | ||||
| } | ||||
|  | ||||
| .sla-text { | ||||
|   @apply text-[0.65rem]; | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -65,6 +65,7 @@ | ||||
|               </template> | ||||
|  | ||||
|               <template #selected="{ selected }"> | ||||
|                 <div v-if="!selected">Select value</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"> | ||||
| @@ -76,7 +77,6 @@ | ||||
|                       </Avatar> | ||||
|                       <span>{{ selected.label }}</span> | ||||
|                     </div> | ||||
|                     <span v-else>Select user</span> | ||||
|                   </div> | ||||
|                 </div> | ||||
|                 <div v-else-if="modelFilter.field === 'assigned_team_id'"> | ||||
| @@ -85,7 +85,6 @@ | ||||
|                       {{ selected.emoji }} | ||||
|                       <span>{{ selected.label }}</span> | ||||
|                     </span> | ||||
|                     <span v-else>Select team</span> | ||||
|                   </div> | ||||
|                 </div> | ||||
|                 <div v-else-if="selected"> | ||||
| @@ -114,7 +113,7 @@ | ||||
|     </div> | ||||
|  | ||||
|     <div class="flex items-center justify-between pt-3"> | ||||
|       <Button variant="ghost" size="sm" @click.prevent="addFilter" class="text-slate-600"> | ||||
|       <Button variant="ghost" size="sm" @click="addFilter" class="text-slate-600"> | ||||
|         <Plus class="w-3 h-3 mr-1" /> Add filter | ||||
|       </Button> | ||||
|       <div class="flex gap-2" v-if="showButtons"> | ||||
| @@ -159,7 +158,7 @@ const createFilter = () => ({ field: '', operator: '', value: '' }) | ||||
|  | ||||
| onMounted(() => { | ||||
|   if (modelValue.value.length === 0) { | ||||
|     modelValue.value.push(createFilter()) | ||||
|     modelValue.value = [createFilter()] | ||||
|   } | ||||
| }) | ||||
|  | ||||
| @@ -171,6 +170,8 @@ const getModel = (field) => { | ||||
|   const fieldConfig = props.fields.find((f) => f.field === field) | ||||
|   return fieldConfig?.model || '' | ||||
| } | ||||
|  | ||||
| // Set model for each filter | ||||
| watch( | ||||
|   () => modelValue.value, | ||||
|   (filters) => { | ||||
| @@ -183,8 +184,25 @@ watch( | ||||
|   { deep: true } | ||||
| ) | ||||
|  | ||||
| const addFilter = () => modelValue.value.push(createFilter()) | ||||
| const removeFilter = (index) => modelValue.value.splice(index, 1) | ||||
| // Reset operator and value when field changes for a filter at a given index | ||||
| watch( | ||||
|   () => modelValue.value.map((f) => f.field), | ||||
|   (newFields, oldFields) => { | ||||
|     newFields.forEach((field, index) => { | ||||
|       if (field !== oldFields[index]) { | ||||
|         modelValue.value[index].operator = '' | ||||
|         modelValue.value[index].value = '' | ||||
|       } | ||||
|     }) | ||||
|   } | ||||
| ) | ||||
|  | ||||
| const addFilter = () => { | ||||
|   modelValue.value = [...modelValue.value, createFilter()] | ||||
| } | ||||
| const removeFilter = (index) => { | ||||
|   modelValue.value = modelValue.value.filter((_, i) => i !== index) | ||||
| } | ||||
| const applyFilters = () => emit('apply', validFilters.value) | ||||
| const clearFilters = () => { | ||||
|   modelValue.value = [] | ||||
|   | ||||
| @@ -1,9 +1,11 @@ | ||||
| <template> | ||||
|   <Dialog :open="openDialog" @update:open="openDialog = false"> | ||||
|     <DialogContent> | ||||
|     <DialogContent class="min-w-[40%] min-h-[30%]"> | ||||
|       <DialogHeader class="space-y-1"> | ||||
|         <DialogTitle>{{ view?.id ? 'Edit' : 'Create' }} view</DialogTitle> | ||||
|         <DialogDescription> Views let you create filters and save them. </DialogDescription> | ||||
|         <DialogDescription> | ||||
|           Create and save custom filter views for quick access to your conversations. | ||||
|         </DialogDescription> | ||||
|       </DialogHeader> | ||||
|       <form @submit.prevent="onSubmit"> | ||||
|         <div class="grid gap-4 py-4"> | ||||
| @@ -11,7 +13,13 @@ | ||||
|             <FormItem> | ||||
|               <FormLabel>Name</FormLabel> | ||||
|               <FormControl> | ||||
|                 <Input id="name" class="col-span-3" placeholder="Name" v-bind="componentField" /> | ||||
|                 <Input | ||||
|                   id="name" | ||||
|                   class="col-span-3" | ||||
|                   placeholder="Name" | ||||
|                   v-bind="componentField" | ||||
|                   @keydown.enter.prevent="onSubmit" | ||||
|                 /> | ||||
|               </FormControl> | ||||
|               <FormDescription>Enter an unique name for your view.</FormDescription> | ||||
|               <FormMessage /> | ||||
| @@ -21,9 +29,13 @@ | ||||
|             <FormItem> | ||||
|               <FormLabel>Filters</FormLabel> | ||||
|               <FormControl> | ||||
|                 <FilterBuilder :fields="filterFields" :showButtons="false" v-bind="componentField" /> | ||||
|                 <FilterBuilder | ||||
|                   :fields="filterFields" | ||||
|                   :showButtons="false" | ||||
|                   v-bind="componentField" | ||||
|                 /> | ||||
|               </FormControl> | ||||
|               <FormDescription>Add multiple filters to customize view.</FormDescription> | ||||
|               <FormDescription> Set one or more filters to customize view.</FormDescription> | ||||
|               <FormMessage /> | ||||
|             </FormItem> | ||||
|           </FormField> | ||||
| @@ -65,6 +77,7 @@ import { toTypedSchema } from '@vee-validate/zod' | ||||
| import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' | ||||
| import { useEmitter } from '@/composables/useEmitter' | ||||
| import { handleHTTPError } from '@/utils/http' | ||||
| import { OPERATOR } from '@/constants/filterConfig.js' | ||||
| import { z } from 'zod' | ||||
| import api from '@/api' | ||||
|  | ||||
| @@ -91,27 +104,53 @@ const formSchema = toTypedSchema( | ||||
|     name: z | ||||
|       .string() | ||||
|       .min(2, { message: 'Name must be at least 2 characters.' }) | ||||
|       .max(250, { message: 'Name cannot exceed 250 characters.' }), | ||||
|       .max(30, { message: 'Name cannot exceed 30 characters.' }), | ||||
|     filters: z | ||||
|       .array( | ||||
|         z.object({ | ||||
|           model: z.string({ required_error: 'Filter required' }), | ||||
|           field: z.string({ required_error: 'Filter required' }), | ||||
|           operator: z.string({ required_error: 'Filter required' }), | ||||
|           value: z.union([z.string(), z.number(), z.boolean()]) | ||||
|           value: z.union([z.string(), z.number(), z.boolean()]).optional() | ||||
|         }) | ||||
|       ) | ||||
|       .default([]) | ||||
|       .refine( | ||||
|         (filters) => filters.length > 0, | ||||
|         { message: 'Please add at least one filter.' } | ||||
|       ) | ||||
|       .refine( | ||||
|         (filters) => | ||||
|           filters.every( | ||||
|             (f) => | ||||
|               f.model && | ||||
|               f.field && | ||||
|               f.operator && | ||||
|               ([OPERATOR.SET, OPERATOR.NOT_SET].includes(f.operator) || f.value) | ||||
|           ), | ||||
|         { | ||||
|           message: "Please make sure you've filled the filter fields correctly." | ||||
|         } | ||||
|       ) | ||||
|   }) | ||||
| ) | ||||
|  | ||||
| const form = useForm({ validationSchema: formSchema }) | ||||
| const form = useForm({ | ||||
|   validationSchema: formSchema, | ||||
|   validateOnMount: false, | ||||
|   validateOnInput: false, | ||||
|   validateOnBlur: false | ||||
| }) | ||||
|  | ||||
| const onSubmit = async () => { | ||||
|   const validationResult = await form.validate() | ||||
|   if (!validationResult.valid) return | ||||
|  | ||||
| const onSubmit = form.handleSubmit(async (values) => { | ||||
|   if (isSubmitting.value) return | ||||
|  | ||||
|   isSubmitting.value = true | ||||
|  | ||||
|   try { | ||||
|     const values = form.values | ||||
|     if (values.id) { | ||||
|       await api.updateView(values.id, values) | ||||
|     } else { | ||||
| @@ -129,8 +168,9 @@ const onSubmit = form.handleSubmit(async (values) => { | ||||
|   } finally { | ||||
|     isSubmitting.value = false | ||||
|   } | ||||
| }) | ||||
| } | ||||
|  | ||||
| // Set form values when view prop changes | ||||
| watch( | ||||
|   () => view.value, | ||||
|   (newVal) => { | ||||
|   | ||||
| @@ -1,11 +1,13 @@ | ||||
| import { createApp } from 'vue' | ||||
| import { createPinia } from 'pinia' | ||||
| import { createI18n } from 'vue-i18n' | ||||
| import { useAppSettingsStore } from './stores/appSettings' | ||||
| import router from './router' | ||||
| import mitt from 'mitt' | ||||
| import api from './api' | ||||
| import './assets/styles/main.scss' | ||||
| import './utils/strings.js' | ||||
| import VueDOMPurifyHTML from 'vue-dompurify-html' | ||||
| import Root from './Root.vue' | ||||
|  | ||||
| const setFavicon = (url) => { | ||||
| @@ -38,13 +40,18 @@ async function initApp () { | ||||
|   const i18n = createI18n(i18nConfig) | ||||
|   const app = createApp(Root) | ||||
|   const pinia = createPinia() | ||||
|   app.use(pinia) | ||||
|  | ||||
|   // Store app settings in Pinia | ||||
|   const settingsStore = useAppSettingsStore() | ||||
|   settingsStore.setSettings(settings) | ||||
|  | ||||
|   // Add emitter to global properties. | ||||
|   app.config.globalProperties.emitter = emitter | ||||
|  | ||||
|   app.use(router) | ||||
|   app.use(pinia) | ||||
|   app.use(i18n) | ||||
|   app.use(VueDOMPurifyHTML) | ||||
|   app.mount('#app') | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -65,7 +65,6 @@ const routes = [ | ||||
|             path: '', | ||||
|             name: 'team-inbox', | ||||
|             component: InboxView, | ||||
|             props: true, | ||||
|             meta: { title: 'Team inbox' } | ||||
|           }, | ||||
|           { | ||||
| @@ -88,7 +87,6 @@ const routes = [ | ||||
|             path: '', | ||||
|             name: 'view-inbox', | ||||
|             component: InboxView, | ||||
|             props: true, | ||||
|             meta: { title: 'View inbox' } | ||||
|           }, | ||||
|           { | ||||
| @@ -118,7 +116,6 @@ const routes = [ | ||||
|             path: '', | ||||
|             name: 'inbox', | ||||
|             component: InboxView, | ||||
|             props: true, | ||||
|             meta: { | ||||
|               title: 'Inbox', | ||||
|               type: route => route.params.type === 'assigned' ? 'My inbox' : route.params.type | ||||
|   | ||||
							
								
								
									
										12
									
								
								frontend/src/stores/appSettings.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								frontend/src/stores/appSettings.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| import { defineStore } from 'pinia' | ||||
|  | ||||
| export const useAppSettingsStore = defineStore('settings', { | ||||
|     state: () => ({ | ||||
|         settings: {} | ||||
|     }), | ||||
|     actions: { | ||||
|         setSettings (newSettings) { | ||||
|             this.settings = newSettings | ||||
|         } | ||||
|     } | ||||
| }) | ||||
| @@ -1,5 +1,5 @@ | ||||
| import { defineStore } from 'pinia' | ||||
| import { computed, reactive, ref } from 'vue' | ||||
| import { computed, reactive, ref, nextTick } from 'vue' | ||||
| import { CONVERSATION_LIST_TYPE, CONVERSATION_DEFAULT_STATUSES } from '@/constants/conversation' | ||||
| import { handleHTTPError } from '@/utils/http' | ||||
| import { useEmitter } from '@/composables/useEmitter' | ||||
| @@ -8,8 +8,8 @@ import MessageCache from '@/utils/conversation-message-cache' | ||||
| import api from '@/api' | ||||
|  | ||||
| export const useConversationStore = defineStore('conversation', () => { | ||||
|   const CONV_LIST_PAGE_SIZE = 100 | ||||
|   const MESSAGE_LIST_PAGE_SIZE = 100 | ||||
|   const CONV_LIST_PAGE_SIZE = 50 | ||||
|   const MESSAGE_LIST_PAGE_SIZE = 30 | ||||
|   const priorities = ref([]) | ||||
|   const statuses = ref([]) | ||||
|  | ||||
| @@ -110,8 +110,11 @@ export const useConversationStore = defineStore('conversation', () => { | ||||
|     clearInterval(reRenderInterval) | ||||
|   } | ||||
|  | ||||
|   function setMacro (macros) { | ||||
|     conversation.macro = macros | ||||
|   async function setMacro (macro) { | ||||
|     // Clear existing macro. | ||||
|     conversation.macro = {} | ||||
|     await nextTick() | ||||
|     conversation.macro = macro | ||||
|   } | ||||
|  | ||||
|   function removeMacroAction (action) { | ||||
| @@ -231,6 +234,10 @@ export const useConversationStore = defineStore('conversation', () => { | ||||
|     return conversation.data || {} | ||||
|   }) | ||||
|  | ||||
|   const hasConversationOpen = computed(() => { | ||||
|     return Object.keys(conversation.data || {}).length > 0 | ||||
|   }) | ||||
|  | ||||
|   const currentBCC = computed(() => { | ||||
|     return conversation.data?.bcc || [] | ||||
|   }) | ||||
| @@ -282,8 +289,10 @@ export const useConversationStore = defineStore('conversation', () => { | ||||
|   async function fetchMessages (uuid, fetchNextPage = false) { | ||||
|     // Messages are already cached? | ||||
|     let hasMessages = messages.data.getAllPagesMessages(uuid) | ||||
|     if (hasMessages.length > 0 && !fetchNextPage) | ||||
|     if (hasMessages.length > 0 && !fetchNextPage) { | ||||
|       markConversationAsRead(uuid) | ||||
|       return | ||||
|     } | ||||
|  | ||||
|     // Fetch messages from server. | ||||
|     messages.loading = true | ||||
| @@ -293,7 +302,6 @@ export const useConversationStore = defineStore('conversation', () => { | ||||
|       const response = await api.getConversationMessages(uuid, { page: page, page_size: MESSAGE_LIST_PAGE_SIZE }) | ||||
|       const result = response.data?.data || {} | ||||
|       const newMessages = result.results || [] | ||||
|       // Mark conversation as read | ||||
|       markConversationAsRead(uuid) | ||||
|       // Cache messages | ||||
|       messages.data.addMessages(uuid, newMessages, result.page, result.total_pages) | ||||
| @@ -608,8 +616,8 @@ export const useConversationStore = defineStore('conversation', () => { | ||||
|     Object.assign(conversation, { | ||||
|       data: null, | ||||
|       participants: {}, | ||||
|       macro: {}, | ||||
|       mediaFiles: [], | ||||
|       macro: {}, | ||||
|       loading: false, | ||||
|       errorMessage: '' | ||||
|     }) | ||||
| @@ -629,6 +637,7 @@ export const useConversationStore = defineStore('conversation', () => { | ||||
|     conversationsList, | ||||
|     conversationMessages, | ||||
|     currentConversationHasMoreMessages, | ||||
|     hasConversationOpen, | ||||
|     current, | ||||
|     currentContactName, | ||||
|     currentBCC, | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| import { ref, computed } from 'vue' | ||||
| import { ref, computed, watch } from 'vue' | ||||
| import { defineStore } from 'pinia' | ||||
| import { handleHTTPError } from '@/utils/http' | ||||
| import { useEmitter } from '@/composables/useEmitter' | ||||
| @@ -6,6 +6,7 @@ import { EMITTER_EVENTS } from '@/constants/emitterEvents' | ||||
| import { adminNavItems, reportsNavItems } from '@/constants/navigation' | ||||
| import { filterNavItems } from '@/utils/nav-permissions' | ||||
| import api from '@/api' | ||||
| import { useStorage } from '@vueuse/core' | ||||
|  | ||||
| export const useUserStore = defineStore('user', () => { | ||||
|   const user = ref({ | ||||
| @@ -15,14 +16,15 @@ export const useUserStore = defineStore('user', () => { | ||||
|     avatar_url: '', | ||||
|     email: '', | ||||
|     teams: [], | ||||
|     permissions: [] | ||||
|     permissions: [], | ||||
|     availability_status: 'offline' | ||||
|   }) | ||||
|   const emitter = useEmitter() | ||||
|  | ||||
|   const userID = computed(() => user.value.id) | ||||
|   const firstName = computed(() => user.value.first_name) | ||||
|   const lastName = computed(() => user.value.last_name) | ||||
|   const avatar = computed(() => user.value.avatar_url) | ||||
|   const firstName = computed(() => user.value.first_name || '') | ||||
|   const lastName = computed(() => user.value.last_name || '') | ||||
|   const avatar = computed(() => user.value.avatar_url || '') | ||||
|   const permissions = computed(() => user.value.permissions || []) | ||||
|   const email = computed(() => user.value.email) | ||||
|   const teams = computed(() => user.value.teams || []) | ||||
| @@ -71,6 +73,10 @@ export const useUserStore = defineStore('user', () => { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   const setCurrentUser = (userData) => { | ||||
|     user.value = userData | ||||
|   } | ||||
|  | ||||
|   const setAvatar = (avatarURL) => { | ||||
|     if (typeof avatarURL !== 'string') { | ||||
|       console.warn('Avatar URL must be a string') | ||||
| @@ -83,6 +89,23 @@ export const useUserStore = defineStore('user', () => { | ||||
|     user.value.avatar_url = '' | ||||
|   } | ||||
|  | ||||
|   // Set and watch user availability status in localStorage to sync across tabs | ||||
|   const availabilityStatusStorage = useStorage('user_availability_status', user.value.availability_status) | ||||
|   watch(availabilityStatusStorage, (newVal) => { | ||||
|     user.value.availability_status = newVal | ||||
|   }) | ||||
|  | ||||
|   const updateUserAvailability = async (status, isManual = true) => { | ||||
|     try { | ||||
|       const apiStatus = status === 'away' && isManual ? 'away_manual' : status | ||||
|       await api.updateCurrentUserAvailability({ status: apiStatus }) | ||||
|       user.value.availability_status = apiStatus | ||||
|       availabilityStatusStorage.value = apiStatus | ||||
|     } catch (error) { | ||||
|       if (error?.response?.status === 401) window.location.href = '/' | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   return { | ||||
|     user, | ||||
|     userID, | ||||
| @@ -96,9 +119,11 @@ export const useUserStore = defineStore('user', () => { | ||||
|     getInitials, | ||||
|     hasAdminTabPermissions, | ||||
|     hasReportTabPermissions, | ||||
|     setCurrentUser, | ||||
|     getCurrentUser, | ||||
|     clearAvatar, | ||||
|     setAvatar, | ||||
|     updateUserAvailability, | ||||
|     can | ||||
|   } | ||||
| }) | ||||
							
								
								
									
										7
									
								
								frontend/src/utils/debounce.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								frontend/src/utils/debounce.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| export function debounce (fn, delay) { | ||||
|     let timeout | ||||
|     return function (...args) { | ||||
|         clearTimeout(timeout) | ||||
|         timeout = setTimeout(() => fn(...args), delay) | ||||
|     } | ||||
| } | ||||
| @@ -48,8 +48,13 @@ export const isGoHourMinuteDuration = (value) => { | ||||
|  | ||||
| const template = document.createElement('template') | ||||
| export function getTextFromHTML(htmlString) { | ||||
|     template.innerHTML = htmlString | ||||
|     const text = template.content.textContent || template.content.innerText || '' | ||||
|     template.innerHTML = '' | ||||
|     return text; | ||||
|     try { | ||||
|         template.innerHTML = htmlString | ||||
|         const text = template.content.textContent || template.content.innerText || '' | ||||
|         template.innerHTML = '' | ||||
|         return text.trim() | ||||
|     } catch (error) { | ||||
|         console.error('Error converting HTML to text:', error) | ||||
|         return '' | ||||
|     } | ||||
| } | ||||
| @@ -155,6 +155,7 @@ import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' | ||||
| import { useEmitter } from '@/composables/useEmitter' | ||||
| import { handleHTTPError } from '@/utils/http' | ||||
| import { SelectTag } from '@/components/ui/select' | ||||
| import { OPERATOR } from '@/constants/filterConfig' | ||||
| import { | ||||
|   Select, | ||||
|   SelectContent, | ||||
| @@ -315,7 +316,8 @@ const handleSave = async (values) => { | ||||
|     emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { | ||||
|       title: 'Invalid rules', | ||||
|       variant: 'destructive', | ||||
|       description: 'Make sure you have atleast one action and one rule.' | ||||
|       description: | ||||
|         'Make sure you have atleast one action and one rule and their values are not empty.' | ||||
|     }) | ||||
|     return | ||||
|   } | ||||
| @@ -347,27 +349,53 @@ const handleSave = async (values) => { | ||||
|   } | ||||
| } | ||||
|  | ||||
| // TODO: Add some vee-validate validations. | ||||
| // TODO: Maybe we can do some vee validate magic here. | ||||
| const areRulesValid = () => { | ||||
|   // Must have groups. | ||||
|   if (rule.value.rules[0].groups.length == 0) { | ||||
|     return false | ||||
|   } | ||||
|  | ||||
|   // At least one group should have at least one rule | ||||
|   const group1HasRules = rule.value.rules[0].groups[0].rules.length > 0 | ||||
|   const group2HasRules = rule.value.rules[0].groups[1].rules.length > 0 | ||||
|   if (!group1HasRules && !group2HasRules) { | ||||
|     return false | ||||
|   } | ||||
|  | ||||
|   // For both groups, each rule should have value, operator and field. | ||||
|   for (const group of rule.value.rules[0].groups) { | ||||
|     for (const rule of group.rules) { | ||||
|       if (!rule.field || !rule.operator) { | ||||
|         return false | ||||
|       } | ||||
|       // For 'set' and `not set` operator, value is not required. | ||||
|       if (rule.operator !== OPERATOR.SET && rule.operator !== OPERATOR.NOT_SET && !rule.value) { | ||||
|         return false | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // Must have atleast one action. | ||||
|   if (rule.value.rules[0].actions.length == 0) { | ||||
|     return false | ||||
|   } | ||||
|  | ||||
|   // Must have atleast 1 group. | ||||
|   if (rule.value.rules[0].groups.length == 0) { | ||||
|     return false | ||||
|   } | ||||
|   // Make sure each action has value. | ||||
|   for (const action of rule.value.rules[0].actions) { | ||||
|     // CSAT action does not require value, set dummy value. | ||||
|     if (action.type === 'send_csat') { | ||||
|       action.value = ['0'] | ||||
|     } | ||||
|  | ||||
|   // Group should have atleast one rule. | ||||
|   if (rule.value.rules[0].groups[0].rules.length == 0) { | ||||
|     return false | ||||
|   } | ||||
|     // Empty array, no value selected. | ||||
|     if (action.value.length === 0) { | ||||
|       return false | ||||
|     } | ||||
|  | ||||
|   // Make sure each rule has all the required fields. | ||||
|   for (const group of rule.value.rules[0].groups) { | ||||
|     for (const rule of group.rules) { | ||||
|       if (!rule.value || !rule.operator || !rule.field) { | ||||
|     // Check if all values are present. | ||||
|     for (const key in action.value) { | ||||
|       if (!action.value[key]) { | ||||
|         return false | ||||
|       } | ||||
|     } | ||||
|   | ||||
| @@ -11,10 +11,7 @@ | ||||
|       </div> | ||||
|     </template> | ||||
|     <template #help> | ||||
|       <p> | ||||
|         Configure core helpdesk settings like helpdesk name, timezone, business hours, and more. | ||||
|       </p> | ||||
|       <p>These settings affect your entire helpdesk system.</p> | ||||
|       <p>General settings for your support desk like timezone, working hours, etc.</p> | ||||
|     </template> | ||||
|   </AdminPageWithHelp> | ||||
| </template> | ||||
|   | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user