mirror of
				https://github.com/abhinavxd/libredesk.git
				synced 2025-11-04 14:03:19 +00:00 
			
		
		
		
	Compare commits
	
		
			113 Commits
		
	
	
		
			mvp
			...
			v0.4.1-alp
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					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 | ||
| 
						 | 
					4e729b91ef | ||
| 
						 | 
					edd629276d | ||
| 
						 | 
					94e9f0f3de | ||
| 
						 | 
					29798c9ba0 | ||
| 
						 | 
					cadf26c8b5 | ||
| 
						 | 
					8358455478 | ||
| 
						 | 
					5d38747bdd | ||
| 
						 | 
					5f3b0c3415 | ||
| 
						 | 
					13f0d2003c | ||
| 
						 | 
					afc2ff45df | ||
| 
						 | 
					605c0aa7a1 | ||
| 
						 | 
					5da727350b | ||
| 
						 | 
					ef077aeac8 | ||
| 
						 | 
					2558f97f0a | ||
| 
						 | 
					501027a0b2 | ||
| 
						 | 
					cc38d8825d | ||
| 
						 | 
					5361bcb24f | ||
| 
						 | 
					730740094f | ||
| 
						 | 
					49761960fd | ||
| 
						 | 
					41c6ebe003 | ||
| 
						 | 
					2ae85ac76a | ||
| 
						 | 
					1a7f53628b | ||
| 
						 | 
					0649633878 | ||
| 
						 | 
					d2a79d9a10 | ||
| 
						 | 
					aba849d344 | ||
| 
						 | 
					3cb584c4d6 | ||
| 
						 | 
					8567baa0e1 | ||
| 
						 | 
					b601724b0a | ||
| 
						 | 
					01c136c469 | ||
| 
						 | 
					a8c61074bb | ||
| 
						 | 
					6324651d01 | ||
| 
						 | 
					62e38814c7 | ||
| 
						 | 
					7eb365c04a | ||
| 
						 | 
					83460ab6a3 | ||
| 
						 | 
					1e44bbbde5 | ||
| 
						 | 
					1f70884628 | ||
| 
						 | 
					f5a4813830 | ||
| 
						 | 
					a2e320473d | ||
| 
						 | 
					2c8900ed95 | ||
| 
						 | 
					2d4356e4f5 | ||
| 
						 | 
					dbb2ae303f | ||
| 
						 | 
					67a7427ab0 | ||
| 
						 | 
					8392371ebf | ||
| 
						 | 
					b8e38424d5 | 
							
								
								
									
										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
 | 
			
		||||
							
								
								
									
										62
									
								
								.github/workflows/release.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								.github/workflows/release.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1,62 @@
 | 
			
		||||
name: Release
 | 
			
		||||
 | 
			
		||||
on:
 | 
			
		||||
  push:
 | 
			
		||||
    tags:
 | 
			
		||||
      - "v*"
 | 
			
		||||
 | 
			
		||||
jobs:
 | 
			
		||||
  release:
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    permissions:
 | 
			
		||||
      contents: write
 | 
			
		||||
      packages: write
 | 
			
		||||
    steps:
 | 
			
		||||
      - name: Checkout repository
 | 
			
		||||
        uses: actions/checkout@v4
 | 
			
		||||
        with:
 | 
			
		||||
          fetch-depth: 0
 | 
			
		||||
 | 
			
		||||
      - name: Set up Docker Buildx
 | 
			
		||||
        uses: docker/setup-buildx-action@v3
 | 
			
		||||
        with:
 | 
			
		||||
          driver: docker-container
 | 
			
		||||
          cache-to: type=gha
 | 
			
		||||
          cache-from: type=gha
 | 
			
		||||
 | 
			
		||||
      - name: Log in to Docker Hub
 | 
			
		||||
        uses: docker/login-action@v3
 | 
			
		||||
        with:
 | 
			
		||||
          username: ${{ secrets.DOCKERHUB_USERNAME }}
 | 
			
		||||
          password: ${{ secrets.DOCKERHUB_PASSWORD }}
 | 
			
		||||
 | 
			
		||||
      - name: Log in to GitHub Container Registry
 | 
			
		||||
        uses: docker/login-action@v3
 | 
			
		||||
        with:
 | 
			
		||||
          registry: ghcr.io
 | 
			
		||||
          username: ${{ github.actor }}
 | 
			
		||||
          password: ${{ secrets.GITHUB_TOKEN }}
 | 
			
		||||
 | 
			
		||||
      - name: Set up Go
 | 
			
		||||
        uses: actions/setup-go@v5
 | 
			
		||||
        with:
 | 
			
		||||
          go-version: "1.21"
 | 
			
		||||
          cache: true
 | 
			
		||||
 | 
			
		||||
      - name: Set up Node.js
 | 
			
		||||
        uses: actions/setup-node@v3
 | 
			
		||||
        with:
 | 
			
		||||
          node-version: '18.12'
 | 
			
		||||
 | 
			
		||||
      - name: Install pnpm
 | 
			
		||||
        run: npm install -g pnpm
 | 
			
		||||
 | 
			
		||||
      - name: Run GoReleaser
 | 
			
		||||
        uses: goreleaser/goreleaser-action@v5
 | 
			
		||||
        with:
 | 
			
		||||
          version: latest
 | 
			
		||||
          args: release --parallelism 1 --clean
 | 
			
		||||
        env:
 | 
			
		||||
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
 | 
			
		||||
          DOCKER_ORG: libredesk
 | 
			
		||||
          GITHUB_ORG: ${{ github.repository_owner }}
 | 
			
		||||
							
								
								
									
										9
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										9
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@@ -1,5 +1,10 @@
 | 
			
		||||
node_modules
 | 
			
		||||
config.toml
 | 
			
		||||
config.toml.*
 | 
			
		||||
libredesk.bin
 | 
			
		||||
uploads/*
 | 
			
		||||
.env
 | 
			
		||||
libredesk
 | 
			
		||||
libredesk.exe
 | 
			
		||||
uploads
 | 
			
		||||
.env
 | 
			
		||||
dist/
 | 
			
		||||
.vscode/
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										182
									
								
								.goreleaser.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										182
									
								
								.goreleaser.yaml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,182 @@
 | 
			
		||||
env:
 | 
			
		||||
  - GO111MODULE=on
 | 
			
		||||
  - CGO_ENABLED=0
 | 
			
		||||
  - GITHUB_ORG=abhinavxd
 | 
			
		||||
  - DOCKER_ORG=libredesk
 | 
			
		||||
 | 
			
		||||
before:
 | 
			
		||||
  hooks:
 | 
			
		||||
    - go mod tidy
 | 
			
		||||
    - make frontend-build
 | 
			
		||||
 | 
			
		||||
builds:
 | 
			
		||||
  - id: "universal"
 | 
			
		||||
    main: ./cmd
 | 
			
		||||
    env:
 | 
			
		||||
      - CGO_ENABLED=0
 | 
			
		||||
    goos:
 | 
			
		||||
      - darwin
 | 
			
		||||
      - freebsd
 | 
			
		||||
      - linux
 | 
			
		||||
      - netbsd
 | 
			
		||||
      - openbsd
 | 
			
		||||
      - windows
 | 
			
		||||
    goarch:
 | 
			
		||||
      - amd64
 | 
			
		||||
      - arm64
 | 
			
		||||
      - arm
 | 
			
		||||
    goarm:
 | 
			
		||||
      - 6
 | 
			
		||||
      - 7
 | 
			
		||||
    binary: 'libredesk{{ if eq .Os "windows" }}.exe{{ end }}'
 | 
			
		||||
    ldflags:
 | 
			
		||||
      - -s -w -X "main.buildString={{ .Tag }} ({{ .ShortCommit }} {{ .Date }}, {{ .Os }}/{{ .Arch }})" -X "main.versionString={{ .Tag }}"
 | 
			
		||||
    hooks:
 | 
			
		||||
      post: make stuff BIN={{ .Path }}
 | 
			
		||||
 | 
			
		||||
archives:
 | 
			
		||||
  - format: tar.gz
 | 
			
		||||
    name_template: 'libredesk_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if eq .Arch "arm" }}v{{ .Arm }}{{ end }}'
 | 
			
		||||
    files:
 | 
			
		||||
      - README.md
 | 
			
		||||
      - LICENSE
 | 
			
		||||
 | 
			
		||||
checksum:
 | 
			
		||||
  name_template: "libredesk_{{ .Version }}_checksums.txt"
 | 
			
		||||
 | 
			
		||||
source:
 | 
			
		||||
  enabled: true
 | 
			
		||||
  format: tar.gz
 | 
			
		||||
  name_template: "libredesk_{{ .Version }}_source"
 | 
			
		||||
 | 
			
		||||
dockers:
 | 
			
		||||
  - use: buildx
 | 
			
		||||
    goos: linux
 | 
			
		||||
    goarch: amd64
 | 
			
		||||
    ids:
 | 
			
		||||
      - universal
 | 
			
		||||
    image_templates:
 | 
			
		||||
      - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-amd64"
 | 
			
		||||
      - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-amd64"
 | 
			
		||||
      - "ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:latest-amd64"
 | 
			
		||||
      - "ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:{{ .Tag }}-amd64"
 | 
			
		||||
    build_flag_templates:
 | 
			
		||||
      - --platform=linux/amd64
 | 
			
		||||
      - --label=org.opencontainers.image.title={{ .ProjectName }}
 | 
			
		||||
      - --label=org.opencontainers.image.description={{ .ProjectName }}
 | 
			
		||||
      - --label=org.opencontainers.image.url=https://github.com/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}
 | 
			
		||||
      - --label=org.opencontainers.image.source=https://github.com/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}
 | 
			
		||||
      - --label=org.opencontainers.image.version={{ .Version }}
 | 
			
		||||
      - --label=org.opencontainers.image.created={{ time "2006-01-02T15:04:05Z07:00" }}
 | 
			
		||||
      - --label=org.opencontainers.image.revision={{ .FullCommit }}
 | 
			
		||||
      - --label=org.opencontainers.image.licenses=AGPL-3.0
 | 
			
		||||
    dockerfile: Dockerfile
 | 
			
		||||
    extra_files:
 | 
			
		||||
      - config.sample.toml
 | 
			
		||||
 | 
			
		||||
  - use: buildx
 | 
			
		||||
    goos: linux
 | 
			
		||||
    goarch: arm64
 | 
			
		||||
    ids:
 | 
			
		||||
      - universal
 | 
			
		||||
    image_templates:
 | 
			
		||||
      - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-arm64"
 | 
			
		||||
      - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-arm64"
 | 
			
		||||
      - "ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:latest-arm64"
 | 
			
		||||
      - "ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:{{ .Tag }}-arm64"
 | 
			
		||||
    build_flag_templates:
 | 
			
		||||
      - --platform=linux/arm64
 | 
			
		||||
      - --label=org.opencontainers.image.title={{ .ProjectName }}
 | 
			
		||||
      - --label=org.opencontainers.image.description={{ .ProjectName }}
 | 
			
		||||
      - --label=org.opencontainers.image.url=https://github.com/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}
 | 
			
		||||
      - --label=org.opencontainers.image.source=https://github.com/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}
 | 
			
		||||
      - --label=org.opencontainers.image.version={{ .Version }}
 | 
			
		||||
      - --label=org.opencontainers.image.created={{ time "2006-01-02T15:04:05Z07:00" }}
 | 
			
		||||
      - --label=org.opencontainers.image.revision={{ .FullCommit }}
 | 
			
		||||
      - --label=org.opencontainers.image.licenses=AGPL-3.0
 | 
			
		||||
    dockerfile: Dockerfile
 | 
			
		||||
    extra_files:
 | 
			
		||||
      - config.sample.toml
 | 
			
		||||
 | 
			
		||||
  - use: buildx
 | 
			
		||||
    goos: linux
 | 
			
		||||
    goarch: arm
 | 
			
		||||
    goarm: 6
 | 
			
		||||
    ids:
 | 
			
		||||
      - universal
 | 
			
		||||
    image_templates:
 | 
			
		||||
      - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-armv6"
 | 
			
		||||
      - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv6"
 | 
			
		||||
      - "ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:latest-armv6"
 | 
			
		||||
      - "ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv6"
 | 
			
		||||
    build_flag_templates:
 | 
			
		||||
      - --platform=linux/arm/v6
 | 
			
		||||
      - --label=org.opencontainers.image.title={{ .ProjectName }}
 | 
			
		||||
      - --label=org.opencontainers.image.description={{ .ProjectName }}
 | 
			
		||||
      - --label=org.opencontainers.image.url=https://github.com/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}
 | 
			
		||||
      - --label=org.opencontainers.image.source=https://github.com/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}
 | 
			
		||||
      - --label=org.opencontainers.image.version={{ .Version }}
 | 
			
		||||
      - --label=org.opencontainers.image.created={{ time "2006-01-02T15:04:05Z07:00" }}
 | 
			
		||||
      - --label=org.opencontainers.image.revision={{ .FullCommit }}
 | 
			
		||||
      - --label=org.opencontainers.image.licenses=AGPL-3.0
 | 
			
		||||
    dockerfile: Dockerfile
 | 
			
		||||
    extra_files:
 | 
			
		||||
      - config.sample.toml
 | 
			
		||||
 | 
			
		||||
  - use: buildx
 | 
			
		||||
    goos: linux
 | 
			
		||||
    goarch: arm
 | 
			
		||||
    goarm: 7
 | 
			
		||||
    ids:
 | 
			
		||||
      - universal
 | 
			
		||||
    image_templates:
 | 
			
		||||
      - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-armv7"
 | 
			
		||||
      - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv7"
 | 
			
		||||
      - "ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:latest-armv7"
 | 
			
		||||
      - "ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv7"
 | 
			
		||||
    build_flag_templates:
 | 
			
		||||
      - --platform=linux/arm/v7
 | 
			
		||||
      - --label=org.opencontainers.image.title={{ .ProjectName }}
 | 
			
		||||
      - --label=org.opencontainers.image.description={{ .ProjectName }}
 | 
			
		||||
      - --label=org.opencontainers.image.url=https://github.com/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}
 | 
			
		||||
      - --label=org.opencontainers.image.source=https://github.com/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}
 | 
			
		||||
      - --label=org.opencontainers.image.version={{ .Version }}
 | 
			
		||||
      - --label=org.opencontainers.image.created={{ time "2006-01-02T15:04:05Z07:00" }}
 | 
			
		||||
      - --label=org.opencontainers.image.revision={{ .FullCommit }}
 | 
			
		||||
      - --label=org.opencontainers.image.licenses=AGPL-3.0
 | 
			
		||||
    dockerfile: Dockerfile
 | 
			
		||||
    extra_files:
 | 
			
		||||
      - config.sample.toml
 | 
			
		||||
 | 
			
		||||
docker_manifests:
 | 
			
		||||
  - name_template: "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest"
 | 
			
		||||
    image_templates:
 | 
			
		||||
      - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-amd64"
 | 
			
		||||
      - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-arm64"
 | 
			
		||||
      - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-armv6"
 | 
			
		||||
      - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-armv7"
 | 
			
		||||
  - name_template: "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}"
 | 
			
		||||
    image_templates:
 | 
			
		||||
      - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-amd64"
 | 
			
		||||
      - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-arm64"
 | 
			
		||||
      - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv6"
 | 
			
		||||
      - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv7"
 | 
			
		||||
  - name_template: ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:latest
 | 
			
		||||
    image_templates:
 | 
			
		||||
      - ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:latest-amd64
 | 
			
		||||
      - ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:latest-arm64
 | 
			
		||||
      - ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:latest-armv6
 | 
			
		||||
      - ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:latest-armv7
 | 
			
		||||
  - name_template: ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:{{ .Tag }}
 | 
			
		||||
    image_templates:
 | 
			
		||||
      - ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:{{ .Tag }}-amd64
 | 
			
		||||
      - ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:{{ .Tag }}-arm64
 | 
			
		||||
      - ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv6
 | 
			
		||||
      - ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv7
 | 
			
		||||
 | 
			
		||||
release:
 | 
			
		||||
  github:
 | 
			
		||||
    owner: abhinavxd
 | 
			
		||||
    name: libredesk
 | 
			
		||||
  prerelease: auto
 | 
			
		||||
  draft: true
 | 
			
		||||
							
								
								
									
										18
									
								
								Dockerfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								Dockerfile
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,18 @@
 | 
			
		||||
# Use the latest version of Alpine Linux as the base image
 | 
			
		||||
FROM alpine:latest
 | 
			
		||||
 | 
			
		||||
# Install necessary packages
 | 
			
		||||
RUN apk --no-cache add ca-certificates
 | 
			
		||||
 | 
			
		||||
# Set the working directory to /libredesk
 | 
			
		||||
WORKDIR /libredesk
 | 
			
		||||
 | 
			
		||||
# Copy necessary files
 | 
			
		||||
COPY libredesk .
 | 
			
		||||
COPY config.sample.toml config.toml
 | 
			
		||||
 | 
			
		||||
# Expose port 9000 for the application
 | 
			
		||||
EXPOSE 9000
 | 
			
		||||
 | 
			
		||||
# Set the default command to run the libredesk binary
 | 
			
		||||
CMD ["./libredesk"]
 | 
			
		||||
							
								
								
									
										34
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										34
									
								
								Makefile
									
									
									
									
									
								
							@@ -1,11 +1,13 @@
 | 
			
		||||
# 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 := libredesk.bin
 | 
			
		||||
BIN := libredesk
 | 
			
		||||
FRONTEND_DIR := frontend
 | 
			
		||||
FRONTEND_DIST := ${FRONTEND_DIR}/dist
 | 
			
		||||
STATIC := ${FRONTEND_DIST} i18n schema.sql static
 | 
			
		||||
@@ -28,15 +30,15 @@ install-deps: $(STUFFBIN)
 | 
			
		||||
 | 
			
		||||
# Build the frontend for production.
 | 
			
		||||
.PHONY: frontend-build
 | 
			
		||||
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,26 +46,26 @@ 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" \
 | 
			
		||||
		-o ${BIN_LIBREDESK} cmd/*.go
 | 
			
		||||
		-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.
 | 
			
		||||
.PHONY: stuff
 | 
			
		||||
stuff: $(STUFFBIN)
 | 
			
		||||
	@echo "→ Stuffing static assets into binary..."
 | 
			
		||||
	@$(STUFFBIN) -a stuff -in ${BIN_LIBREDESK} -out ${BIN_LIBREDESK} ${STATIC}
 | 
			
		||||
	@$(STUFFBIN) -a stuff -in ${BIN} -out ${BIN} ${STATIC}
 | 
			
		||||
 | 
			
		||||
# Build the application in demo mode.
 | 
			
		||||
.PHONY: demo-build
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										93
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										93
									
								
								README.md
									
									
									
									
									
								
							@@ -1,39 +1,82 @@
 | 
			
		||||
<a href="https://zerodha.tech"><img src="https://zerodha.tech/static/images/github-badge.svg" align="right" alt="Zerodha Tech Badge" /></a>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# Libredesk
 | 
			
		||||
 | 
			
		||||
Open-source, self-hosted customer support desk. Single binary app.
 | 
			
		||||
Open source, self-hosted customer support desk. Single binary app.
 | 
			
		||||
 | 
			
		||||
> This project is currently in **alpha**. Features and APIs may change and are not yet fully tested.
 | 
			
		||||
Visit [libredesk.io](https://libredesk.io) for more info. Check out the [**Live demo**](https://demo.libredesk.io/).
 | 
			
		||||
 | 
			
		||||
## Developer Setup
 | 
			
		||||

 | 
			
		||||
 | 
			
		||||
#### Prerequisites
 | 
			
		||||
 | 
			
		||||
- **go**
 | 
			
		||||
- **pnpm**
 | 
			
		||||
- **PostgreSQL >= 13**
 | 
			
		||||
- **Redis**
 | 
			
		||||
> **CAUTION:** This project is currently in **alpha**. Features and APIs may change and are not yet fully tested.
 | 
			
		||||
 | 
			
		||||
1. **Clone the repository**:
 | 
			
		||||
## Features
 | 
			
		||||
 | 
			
		||||
   ```bash
 | 
			
		||||
   git clone https://github.com/abhinavxd/libredesk.git
 | 
			
		||||
   cd libredesk
 | 
			
		||||
   ```
 | 
			
		||||
- **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.
 | 
			
		||||
 | 
			
		||||
2. **Configure the Application**:
 | 
			
		||||
And more checkout - [libredesk.io](https://libredesk.io)
 | 
			
		||||
 | 
			
		||||
   - Copy the sample configuration file `config.toml.sample` to `config.toml`:
 | 
			
		||||
    
 | 
			
		||||
       ```bash
 | 
			
		||||
       cp config.toml.sample config.toml
 | 
			
		||||
       ```
 | 
			
		||||
   - Edit the `config.toml` file to configure your database and Redis connection settings.
 | 
			
		||||
 | 
			
		||||
3. **Run in Development Mode**:
 | 
			
		||||
## Installation
 | 
			
		||||
 | 
			
		||||
   - Backend: `make run-backend`
 | 
			
		||||
   - Frontend: `make run-frontend`
 | 
			
		||||
### Docker
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
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)
 | 
			
		||||
 | 
			
		||||
Visit [libredesk.io](https://libredesk.io) for more info.
 | 
			
		||||
```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
 | 
			
		||||
 | 
			
		||||
# 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 username `System` and the password you set using the --set-system-user-password command.
 | 
			
		||||
 | 
			
		||||
See [installation docs](https://libredesk.io/docs/installation/)
 | 
			
		||||
 | 
			
		||||
__________________
 | 
			
		||||
 | 
			
		||||
### 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)
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -37,8 +37,9 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
 | 
			
		||||
	// OpenID connect single sign-on.
 | 
			
		||||
	g.GET("/api/v1/oidc/enabled", handleGetAllEnabledOIDC)
 | 
			
		||||
	g.GET("/api/v1/oidc", perm(handleGetAllOIDC, "oidc:manage"))
 | 
			
		||||
	g.GET("/api/v1/oidc/{id}", perm(handleGetOIDC, "oidc:manage"))
 | 
			
		||||
	g.POST("/api/v1/oidc", perm(handleCreateOIDC, "oidc:manage"))
 | 
			
		||||
	g.POST("/api/v1/oidc/test", perm(handleTestOIDC, "oidc:manage"))
 | 
			
		||||
	g.GET("/api/v1/oidc/{id}", perm(handleGetOIDC, "oidc:manage"))
 | 
			
		||||
	g.PUT("/api/v1/oidc/{id}", perm(handleUpdateOIDC, "oidc:manage"))
 | 
			
		||||
	g.DELETE("/api/v1/oidc/{id}", perm(handleDeleteOIDC, "oidc:manage"))
 | 
			
		||||
 | 
			
		||||
@@ -62,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"))
 | 
			
		||||
@@ -98,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"))
 | 
			
		||||
@@ -172,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 {
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										10
									
								
								cmd/init.go
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								cmd/init.go
									
									
									
									
									
								
							@@ -98,6 +98,9 @@ func initFlags() {
 | 
			
		||||
		"path to one or more config files (will be merged in order)")
 | 
			
		||||
	f.Bool("version", false, "show current version of the build")
 | 
			
		||||
	f.Bool("install", false, "setup database")
 | 
			
		||||
	f.Bool("idempotent-install", false, "run idempotent installation, i.e., skip installion if schema is already installed useful for the first time setup")
 | 
			
		||||
	f.Bool("yes", false, "skip confirmation prompt")
 | 
			
		||||
	f.Bool("upgrade", false, "upgrade the database schema")
 | 
			
		||||
	f.Bool("set-system-user-password", false, "set password for the system user")
 | 
			
		||||
 | 
			
		||||
	if err := f.Parse(os.Args[1:]); err != nil {
 | 
			
		||||
@@ -305,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 (
 | 
			
		||||
@@ -546,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
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -4,23 +4,38 @@ import (
 | 
			
		||||
	"context"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"log"
 | 
			
		||||
	"os"
 | 
			
		||||
	"strings"
 | 
			
		||||
	"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.
 | 
			
		||||
func install(ctx context.Context, db *sqlx.DB, fs stuffbin.FileSystem) error {
 | 
			
		||||
	installed, err := checkSchema(db)
 | 
			
		||||
// Install checks if the schema is already installed, prompts for confirmation, and installs the schema if needed.
 | 
			
		||||
// idempotent install skips the installation if the database schema is already installed.
 | 
			
		||||
func install(ctx context.Context, db *sqlx.DB, fs stuffbin.FileSystem, idempotentInstall, prompt bool) error {
 | 
			
		||||
	schemaInstalled, err := checkSchema(db)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		log.Fatalf("error checking db schema: %v", err)
 | 
			
		||||
		log.Fatalf("error checking existing db schema: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
	if installed {
 | 
			
		||||
		fmt.Printf("\033[31m** WARNING: This will wipe your entire database - '%s' **\033[0m\n", ko.String("db.database"))
 | 
			
		||||
		fmt.Print("Continue (y/n)? ")
 | 
			
		||||
 | 
			
		||||
	// Make sure the system user password is strong enough.
 | 
			
		||||
	password := os.Getenv("LIBREDESK_SYSTEM_USER_PASSWORD")
 | 
			
		||||
	if password != "" && !user.IsStrongSystemUserPassword(password) && !schemaInstalled {
 | 
			
		||||
		log.Fatalf("system user password is not strong, %s", user.SystemUserPasswordHint)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if !idempotentInstall {
 | 
			
		||||
		log.Println("running first time setup...")
 | 
			
		||||
		colorlog.Red(fmt.Sprintf("WARNING: This will wipe your entire database - '%s'", ko.String("db.database")))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if prompt {
 | 
			
		||||
		log.Print("Continue (y/n)? ")
 | 
			
		||||
		var ok string
 | 
			
		||||
		fmt.Scanf("%s", &ok)
 | 
			
		||||
		if !strings.EqualFold(ok, "y") {
 | 
			
		||||
@@ -28,15 +43,26 @@ func install(ctx context.Context, db *sqlx.DB, fs stuffbin.FileSystem) error {
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if idempotentInstall {
 | 
			
		||||
		if schemaInstalled {
 | 
			
		||||
			log.Println("skipping installation as schema is already installed")
 | 
			
		||||
			os.Exit(0)
 | 
			
		||||
		}
 | 
			
		||||
	} else {
 | 
			
		||||
		time.Sleep(5 * time.Second)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	log.Println("installing database schema...")
 | 
			
		||||
 | 
			
		||||
	// Install schema.
 | 
			
		||||
	if err := installSchema(db, fs); err != nil {
 | 
			
		||||
		log.Fatalf("error installing schema: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	log.Println("Schema installed successfully")
 | 
			
		||||
	log.Println("database schema installed successfully")
 | 
			
		||||
 | 
			
		||||
	// Create system user.
 | 
			
		||||
	if err := user.CreateSystemUser(ctx, db); err != nil {
 | 
			
		||||
	if err := user.CreateSystemUser(ctx, password, db); err != nil {
 | 
			
		||||
		log.Fatalf("error creating system user: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
	return nil
 | 
			
		||||
@@ -50,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
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										36
									
								
								cmd/main.go
									
									
									
									
									
								
							
							
						
						
									
										36
									
								
								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)
 | 
			
		||||
@@ -114,7 +119,7 @@ func main() {
 | 
			
		||||
 | 
			
		||||
	// Installer.
 | 
			
		||||
	if ko.Bool("install") {
 | 
			
		||||
		install(ctx, db, fs)
 | 
			
		||||
		install(ctx, db, fs, ko.Bool("idempotent-install"), !ko.Bool("yes"))
 | 
			
		||||
		os.Exit(0)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@@ -130,10 +135,19 @@ func main() {
 | 
			
		||||
		log.Fatalf("error checking db schema: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
	if !installed {
 | 
			
		||||
		log.Println("Database tables are missing. Use the `--install` flag to set up the database schema.")
 | 
			
		||||
		log.Println("database tables are missing. Use the `--install` flag to set up the database schema.")
 | 
			
		||||
		os.Exit(0)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Upgrade.
 | 
			
		||||
	if ko.Bool("upgrade") {
 | 
			
		||||
		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)
 | 
			
		||||
@@ -147,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)
 | 
			
		||||
@@ -162,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)
 | 
			
		||||
@@ -178,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,
 | 
			
		||||
@@ -233,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),
 | 
			
		||||
		}, "")
 | 
			
		||||
	}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										42
									
								
								cmd/oidc.go
									
									
									
									
									
								
							
							
						
						
									
										42
									
								
								cmd/oidc.go
									
									
									
									
									
								
							@@ -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)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -44,6 +50,19 @@ func handleGetOIDC(r *fastglue.Request) error {
 | 
			
		||||
	return r.SendEnvelope(o)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// handleTestOIDC tests an OIDC provider URL by doing a discovery on the provider URL.
 | 
			
		||||
func handleTestOIDC(r *fastglue.Request) error {
 | 
			
		||||
	var (
 | 
			
		||||
		app         = r.Context.(*App)
 | 
			
		||||
		providerURL = string(r.RequestCtx.PostArgs().Peek("provider_url"))
 | 
			
		||||
	)
 | 
			
		||||
	if err := app.auth.TestProvider(providerURL); err != nil {
 | 
			
		||||
		return sendErrorEnvelope(r, err)
 | 
			
		||||
	}
 | 
			
		||||
	return r.SendEnvelope("OIDC provider discovered successfully")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// handleCreateOIDC creates a new OIDC record.
 | 
			
		||||
func handleCreateOIDC(r *fastglue.Request) error {
 | 
			
		||||
	var (
 | 
			
		||||
		app = r.Context.(*App)
 | 
			
		||||
@@ -52,18 +71,19 @@ func handleCreateOIDC(r *fastglue.Request) error {
 | 
			
		||||
	if err := r.Decode(&req, "json"); err != nil {
 | 
			
		||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Bad request", nil, envelope.GeneralError)
 | 
			
		||||
	}
 | 
			
		||||
	err := app.oidc.Create(req)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
 | 
			
		||||
	if err := app.oidc.Create(req); err != nil {
 | 
			
		||||
		return sendErrorEnvelope(r, err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Reload the auth manager to update the OIDC providers.
 | 
			
		||||
	if err := reloadAuth(app); err != nil {
 | 
			
		||||
		return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error reloading auth", nil, envelope.GeneralError)
 | 
			
		||||
		return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, envelope.GeneralError)
 | 
			
		||||
	}
 | 
			
		||||
	return r.SendEnvelope("OIDC created successfully")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// handleUpdateOIDC updates an OIDC record.
 | 
			
		||||
func handleUpdateOIDC(r *fastglue.Request) error {
 | 
			
		||||
	var (
 | 
			
		||||
		app = r.Context.(*App)
 | 
			
		||||
@@ -79,8 +99,7 @@ func handleUpdateOIDC(r *fastglue.Request) error {
 | 
			
		||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Bad request", nil, envelope.GeneralError)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	err = app.oidc.Update(id, req)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
	if err = app.oidc.Update(id, req); err != nil {
 | 
			
		||||
		return sendErrorEnvelope(r, err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@@ -91,23 +110,16 @@ func handleUpdateOIDC(r *fastglue.Request) error {
 | 
			
		||||
	return r.SendEnvelope("OIDC updated successfully")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// handleDeleteOIDC deletes an OIDC record.
 | 
			
		||||
func handleDeleteOIDC(r *fastglue.Request) error {
 | 
			
		||||
	var (
 | 
			
		||||
		app = r.Context.(*App)
 | 
			
		||||
	)
 | 
			
		||||
	var app = r.Context.(*App)
 | 
			
		||||
	id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
 | 
			
		||||
	if err != nil || id == 0 {
 | 
			
		||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
 | 
			
		||||
			"Invalid oidc `id`.", nil, envelope.InputError)
 | 
			
		||||
	}
 | 
			
		||||
	err = app.oidc.Delete(id)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
	if err = app.oidc.Delete(id); err != nil {
 | 
			
		||||
		return sendErrorEnvelope(r, err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Reload the auth manager to update the OIDC providers.
 | 
			
		||||
	if err := reloadAuth(app); err != nil {
 | 
			
		||||
		return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, envelope.GeneralError)
 | 
			
		||||
	}
 | 
			
		||||
	return r.SendEnvelope("OIDC deleted successfully")
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										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]
 | 
			
		||||
@@ -9,16 +10,16 @@ address = "0.0.0.0:9000"
 | 
			
		||||
socket = ""
 | 
			
		||||
read_timeout = "5s"
 | 
			
		||||
write_timeout = "5s"
 | 
			
		||||
max_body_size = 10000000
 | 
			
		||||
max_body_size = 500000000
 | 
			
		||||
keepalive_timeout = "10s"
 | 
			
		||||
 | 
			
		||||
# File upload provider.
 | 
			
		||||
# File upload provider to use, either `fs` or `s3`.
 | 
			
		||||
[upload]
 | 
			
		||||
provider = "fs"
 | 
			
		||||
 | 
			
		||||
# Filesytem provider.
 | 
			
		||||
[upload.fs]
 | 
			
		||||
upload_path = '/home/ubuntu/uploads'
 | 
			
		||||
upload_path = 'uploads'
 | 
			
		||||
 | 
			
		||||
# S3 provider.
 | 
			
		||||
[upload.s3]
 | 
			
		||||
@@ -32,10 +33,12 @@ expiry = "6h"
 | 
			
		||||
 | 
			
		||||
# Postgres.
 | 
			
		||||
[db]
 | 
			
		||||
# 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
 | 
			
		||||
@@ -44,6 +47,7 @@ max_lifetime = "300s"
 | 
			
		||||
 | 
			
		||||
# Redis.
 | 
			
		||||
[redis]
 | 
			
		||||
# If using docker compose, use the service name as the host. e.g. redis:6379
 | 
			
		||||
address = "127.0.0.1:6379"
 | 
			
		||||
password = ""
 | 
			
		||||
db = 0
 | 
			
		||||
@@ -69,4 +73,4 @@ autoassign_interval = "5m"
 | 
			
		||||
unsnooze_interval = "5m"
 | 
			
		||||
 | 
			
		||||
[sla]
 | 
			
		||||
evaluation_interval = "5m"
 | 
			
		||||
evaluation_interval = "5m"
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										62
									
								
								docker-compose.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								docker-compose.yml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,62 @@
 | 
			
		||||
services:
 | 
			
		||||
  # Libredesk app
 | 
			
		||||
  app:
 | 
			
		||||
    image: libredesk/libredesk:latest
 | 
			
		||||
    container_name: libredesk_app
 | 
			
		||||
    restart: unless-stopped
 | 
			
		||||
    ports:
 | 
			
		||||
      - "9000:9000"
 | 
			
		||||
    environment:
 | 
			
		||||
      # If the password is set during first docker-compose up, the system user password will be set to this value.
 | 
			
		||||
      # You can always set system user password later by running `docker exec -it libredesk_app ./libredesk --set-system-user-password`.
 | 
			
		||||
      LIBREDESK_SYSTEM_USER_PASSWORD: ${LIBREDESK_SYSTEM_USER_PASSWORD:-}
 | 
			
		||||
    networks:
 | 
			
		||||
      - libredesk
 | 
			
		||||
    depends_on:
 | 
			
		||||
      - db
 | 
			
		||||
      - redis
 | 
			
		||||
    volumes:
 | 
			
		||||
      - ./uploads:/libredesk/uploads:rw 
 | 
			
		||||
      - ./config.toml:/libredesk/config.toml
 | 
			
		||||
    command: [sh, -c, "./libredesk --install --idempotent-install --yes --config /libredesk/config.toml && ./libredesk --upgrade --yes --config /libredesk/config.toml && ./libredesk --config /libredesk/config.toml"]
 | 
			
		||||
 | 
			
		||||
  # PostgreSQL database
 | 
			
		||||
  db:
 | 
			
		||||
    image: postgres:17-alpine
 | 
			
		||||
    container_name: libredesk_db
 | 
			
		||||
    restart: unless-stopped
 | 
			
		||||
    networks:
 | 
			
		||||
      - libredesk
 | 
			
		||||
    ports:
 | 
			
		||||
      - "5432:5432"
 | 
			
		||||
    environment:
 | 
			
		||||
      # Set these environment variables to configure the database, defaults to libredesk.
 | 
			
		||||
      POSTGRES_USER: ${POSTGRES_USER:-libredesk}
 | 
			
		||||
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-libredesk} 
 | 
			
		||||
      POSTGRES_DB: ${POSTGRES_DB:-libredesk}
 | 
			
		||||
    healthcheck:
 | 
			
		||||
      test: ["CMD-SHELL", "pg_isready -U libredesk"]
 | 
			
		||||
      interval: 10s
 | 
			
		||||
      timeout: 5s
 | 
			
		||||
      retries: 6
 | 
			
		||||
    volumes:
 | 
			
		||||
      - postgres-data:/var/lib/postgresql/data
 | 
			
		||||
 | 
			
		||||
  # Redis
 | 
			
		||||
  redis:
 | 
			
		||||
    image: redis:7-alpine
 | 
			
		||||
    container_name: libredesk_redis
 | 
			
		||||
    restart: unless-stopped
 | 
			
		||||
    ports:
 | 
			
		||||
      - "6379:6379"
 | 
			
		||||
    networks:
 | 
			
		||||
      - libredesk
 | 
			
		||||
    volumes:
 | 
			
		||||
      - redis-data:/data
 | 
			
		||||
 | 
			
		||||
networks:
 | 
			
		||||
  libredesk:
 | 
			
		||||
 | 
			
		||||
volumes:
 | 
			
		||||
  postgres-data:
 | 
			
		||||
  redis-data:
 | 
			
		||||
							
								
								
									
										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,18 @@
 | 
			
		||||
        @create-view="openCreateViewForm = true"
 | 
			
		||||
        @edit-view="editView"
 | 
			
		||||
        @delete-view="deleteView"
 | 
			
		||||
        @create-conversation="() => openCreateConversationDialog = true"
 | 
			
		||||
      >
 | 
			
		||||
        <PageHeader />
 | 
			
		||||
        <RouterView />
 | 
			
		||||
        <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" />
 | 
			
		||||
      </Sidebar>
 | 
			
		||||
    </div>
 | 
			
		||||
@@ -56,6 +65,9 @@
 | 
			
		||||
 | 
			
		||||
  <!-- Command box -->
 | 
			
		||||
  <Command />
 | 
			
		||||
 | 
			
		||||
  <!-- Create conversation dialog -->
 | 
			
		||||
  <CreateConversation v-model="openCreateConversationDialog" />
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup>
 | 
			
		||||
@@ -73,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 {
 | 
			
		||||
@@ -107,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()
 | 
			
		||||
@@ -117,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'
 | 
			
		||||
</script>
 | 
			
		||||
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}`)
 | 
			
		||||
@@ -90,6 +91,7 @@ const createOIDC = (data) =>
 | 
			
		||||
      'Content-Type': 'application/json'
 | 
			
		||||
    }
 | 
			
		||||
  })
 | 
			
		||||
const testOIDC = (data) => http.post('/api/v1/oidc/test', data)
 | 
			
		||||
const getAllEnabledOIDC = () => http.get('/api/v1/oidc/enabled')
 | 
			
		||||
const getAllOIDC = () => http.get('/api/v1/oidc')
 | 
			
		||||
const getOIDC = (id) => http.get(`/api/v1/oidc/${id}`)
 | 
			
		||||
@@ -168,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`)
 | 
			
		||||
@@ -263,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,
 | 
			
		||||
@@ -322,12 +327,15 @@ export default {
 | 
			
		||||
  uploadMedia,
 | 
			
		||||
  updateAssigneeLastSeen,
 | 
			
		||||
  updateUser,
 | 
			
		||||
  updateCurrentUserAvailability,
 | 
			
		||||
  updateAutomationRule,
 | 
			
		||||
  updateAutomationRuleWeights,
 | 
			
		||||
  updateAutomationRulesExecutionMode,
 | 
			
		||||
  updateAIProvider,
 | 
			
		||||
  createAutomationRule,
 | 
			
		||||
  toggleAutomationRule,
 | 
			
		||||
  deleteAutomationRule,
 | 
			
		||||
  createConversation,
 | 
			
		||||
  sendMessage,
 | 
			
		||||
  retryMessage,
 | 
			
		||||
  createUser,
 | 
			
		||||
@@ -344,6 +352,7 @@ export default {
 | 
			
		||||
  getAllEnabledOIDC,
 | 
			
		||||
  getOIDC,
 | 
			
		||||
  updateOIDC,
 | 
			
		||||
  testOIDC,
 | 
			
		||||
  deleteOIDC,
 | 
			
		||||
  getTemplate,
 | 
			
		||||
  getTemplates,
 | 
			
		||||
@@ -371,5 +380,6 @@ export default {
 | 
			
		||||
  aiCompletion,
 | 
			
		||||
  searchConversations,
 | 
			
		||||
  searchMessages,
 | 
			
		||||
  searchContacts,
 | 
			
		||||
  removeAssignee,
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -6,13 +6,6 @@
 | 
			
		||||
  font-size: 16px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.page-content {
 | 
			
		||||
  height: 100vh;
 | 
			
		||||
  overflow-y: scroll;
 | 
			
		||||
  padding-bottom: 100px;
 | 
			
		||||
  background-color: #fff;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@layer base {
 | 
			
		||||
  html,
 | 
			
		||||
  body {
 | 
			
		||||
@@ -25,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.
 | 
			
		||||
@@ -32,66 +68,65 @@
 | 
			
		||||
  :root {
 | 
			
		||||
    --background: 0 0% 100%;
 | 
			
		||||
    --foreground: 240 10% 3.9%;
 | 
			
		||||
  
 | 
			
		||||
 | 
			
		||||
    --card: 0 0% 100%;
 | 
			
		||||
    --card-foreground: 240 10% 3.9%;
 | 
			
		||||
  
 | 
			
		||||
 | 
			
		||||
    --popover: 0 0% 100%;
 | 
			
		||||
    --popover-foreground: 240 10% 3.9%;
 | 
			
		||||
  
 | 
			
		||||
 | 
			
		||||
    --primary: 240 5.9% 10%;
 | 
			
		||||
    --primary-foreground: 0 0% 98%;
 | 
			
		||||
  
 | 
			
		||||
 | 
			
		||||
    --secondary: 240 4.8% 95.9%;
 | 
			
		||||
    --secondary-foreground: 240 5.9% 10%;
 | 
			
		||||
  
 | 
			
		||||
 | 
			
		||||
    --muted: 240 4.8% 95.9%;
 | 
			
		||||
    --muted-foreground: 240 3.8% 46.1%;
 | 
			
		||||
  
 | 
			
		||||
 | 
			
		||||
    --accent: 240 4.8% 95.9%;
 | 
			
		||||
    --accent-foreground: 240 5.9% 10%;
 | 
			
		||||
  
 | 
			
		||||
 | 
			
		||||
    --destructive: 0 84.2% 60.2%;
 | 
			
		||||
    --destructive-foreground: 0 0% 98%;
 | 
			
		||||
  
 | 
			
		||||
    --border:240 5.9% 90%;
 | 
			
		||||
    --input:240 5.9% 90%;
 | 
			
		||||
    --ring:240 5.9% 10%;
 | 
			
		||||
 | 
			
		||||
    --border: 240 5.9% 90%;
 | 
			
		||||
    --input: 240 5.9% 90%;
 | 
			
		||||
    --ring: 240 5.9% 10%;
 | 
			
		||||
    --radius: 0.75rem;
 | 
			
		||||
  }
 | 
			
		||||
   
 | 
			
		||||
 | 
			
		||||
  .dark {
 | 
			
		||||
    --background:240 10% 3.9%;
 | 
			
		||||
    --foreground:0 0% 98%;
 | 
			
		||||
  
 | 
			
		||||
    --card:240 10% 3.9%;
 | 
			
		||||
    --card-foreground:0 0% 98%;
 | 
			
		||||
  
 | 
			
		||||
    --popover:240 10% 3.9%;
 | 
			
		||||
    --popover-foreground:0 0% 98%;
 | 
			
		||||
  
 | 
			
		||||
    --primary:0 0% 98%;
 | 
			
		||||
    --primary-foreground:240 5.9% 10%;
 | 
			
		||||
  
 | 
			
		||||
    --secondary:240 3.7% 15.9%;
 | 
			
		||||
    --secondary-foreground:0 0% 98%;
 | 
			
		||||
  
 | 
			
		||||
    --muted:240 3.7% 15.9%;
 | 
			
		||||
    --muted-foreground:240 5% 64.9%;
 | 
			
		||||
  
 | 
			
		||||
    --accent:240 3.7% 15.9%;
 | 
			
		||||
    --accent-foreground:0 0% 98%;
 | 
			
		||||
  
 | 
			
		||||
    --destructive:0 62.8% 30.6%;
 | 
			
		||||
    --destructive-foreground:0 0% 98%;
 | 
			
		||||
  
 | 
			
		||||
    --border:240 3.7% 15.9%;
 | 
			
		||||
    --input:240 3.7% 15.9%;
 | 
			
		||||
    --ring:240 4.9% 83.9%;
 | 
			
		||||
    --background: 240 10% 3.9%;
 | 
			
		||||
    --foreground: 0 0% 98%;
 | 
			
		||||
 | 
			
		||||
    --card: 240 10% 3.9%;
 | 
			
		||||
    --card-foreground: 0 0% 98%;
 | 
			
		||||
 | 
			
		||||
    --popover: 240 10% 3.9%;
 | 
			
		||||
    --popover-foreground: 0 0% 98%;
 | 
			
		||||
 | 
			
		||||
    --primary: 0 0% 98%;
 | 
			
		||||
    --primary-foreground: 240 5.9% 10%;
 | 
			
		||||
 | 
			
		||||
    --secondary: 240 3.7% 15.9%;
 | 
			
		||||
    --secondary-foreground: 0 0% 98%;
 | 
			
		||||
 | 
			
		||||
    --muted: 240 3.7% 15.9%;
 | 
			
		||||
    --muted-foreground: 240 5% 64.9%;
 | 
			
		||||
 | 
			
		||||
    --accent: 240 3.7% 15.9%;
 | 
			
		||||
    --accent-foreground: 0 0% 98%;
 | 
			
		||||
 | 
			
		||||
    --destructive: 0 62.8% 30.6%;
 | 
			
		||||
    --destructive-foreground: 0 0% 98%;
 | 
			
		||||
 | 
			
		||||
    --border: 240 3.7% 15.9%;
 | 
			
		||||
    --input: 240 3.7% 15.9%;
 | 
			
		||||
    --ring: 240 4.9% 83.9%;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@layer base {
 | 
			
		||||
  :root {
 | 
			
		||||
    --vis-tooltip-background-color: none !important;
 | 
			
		||||
@@ -239,7 +274,7 @@
 | 
			
		||||
// Sidebar start
 | 
			
		||||
@layer base {
 | 
			
		||||
  :root {
 | 
			
		||||
    --sidebar-background: 0 0% 97%;
 | 
			
		||||
    --sidebar-background: 0 0% 96%;
 | 
			
		||||
    --sidebar-foreground: 240 5.3% 26.1%;
 | 
			
		||||
    --sidebar-primary: 240 5.9% 10%;
 | 
			
		||||
    --sidebar-primary-foreground: 0 0% 98%;
 | 
			
		||||
@@ -320,3 +355,7 @@ a[data-active='false']:hover {
 | 
			
		||||
    opacity: 1;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
[data-radix-popper-content-wrapper] {
 | 
			
		||||
  z-index: 9999 !important;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,8 +1,8 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div v-if="!isHidden">
 | 
			
		||||
    <div class="flex items-center space-x-4 p-4">
 | 
			
		||||
    <div class="flex items-center space-x-4 h-12 px-2">
 | 
			
		||||
      <SidebarTrigger class="cursor-pointer w-4 h-4" />
 | 
			
		||||
      <span class="text-2xl font-semibold">
 | 
			
		||||
      <span class="text-xl font-semibold text-gray-800">
 | 
			
		||||
        {{ title }}
 | 
			
		||||
      </span>
 | 
			
		||||
    </div>
 | 
			
		||||
 
 | 
			
		||||
@@ -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>
 | 
			
		||||
</script>
 | 
			
		||||
 
 | 
			
		||||
@@ -2,9 +2,10 @@
 | 
			
		||||
  <Breadcrumb>
 | 
			
		||||
    <BreadcrumbList>
 | 
			
		||||
      <BreadcrumbItem v-for="(item, index) in links" :key="index">
 | 
			
		||||
        <router-link :to="item.path">
 | 
			
		||||
        <router-link :to="{ name: item.path }" v-if="item.path">
 | 
			
		||||
          {{ item.label }}
 | 
			
		||||
        </router-link>
 | 
			
		||||
        <span v-else>{{ item.label }}</span>
 | 
			
		||||
        <BreadcrumbSeparator v-if="index < links.length - 1">
 | 
			
		||||
          <ChevronRight />
 | 
			
		||||
        </BreadcrumbSeparator>
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -8,8 +8,6 @@ export const CONVERSATION_LIST_TYPE = {
 | 
			
		||||
 | 
			
		||||
export const CONVERSATION_DEFAULT_STATUSES = {
 | 
			
		||||
  OPEN: 'Open',
 | 
			
		||||
  IN_PROGRESS: 'In Progress',
 | 
			
		||||
  WAITING: 'Waiting',
 | 
			
		||||
  SNOOZED: 'Snoozed',
 | 
			
		||||
  RESOLVED: 'Resolved',
 | 
			
		||||
  CLOSED: 'Closed',
 | 
			
		||||
 
 | 
			
		||||
@@ -112,7 +112,7 @@ export const adminNavItems = [
 | 
			
		||||
        children: [
 | 
			
		||||
            {
 | 
			
		||||
                title: 'SSO',
 | 
			
		||||
                href: '/admin/oidc',
 | 
			
		||||
                href: '/admin/sso',
 | 
			
		||||
                permission: 'oidc:manage'
 | 
			
		||||
            }
 | 
			
		||||
        ]
 | 
			
		||||
 
 | 
			
		||||
@@ -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"
 | 
			
		||||
 
 | 
			
		||||
@@ -120,7 +120,7 @@
 | 
			
		||||
                </DialogFooter>
 | 
			
		||||
            </DialogContent>
 | 
			
		||||
        </Dialog>
 | 
			
		||||
        <Button type="submit" :disabled="isLoading">{{ submitLabel }}</Button>
 | 
			
		||||
        <Button type="submit" :disabled="isLoading" :isLoading="isLoading">{{ submitLabel }}</Button>
 | 
			
		||||
    </form>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,8 +1,5 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <form
 | 
			
		||||
    @submit="onSubmit"
 | 
			
		||||
    class="space-y-6 w-full"
 | 
			
		||||
  >
 | 
			
		||||
  <form @submit="onSubmit" class="space-y-6 w-full">
 | 
			
		||||
    <FormField v-slot="{ field }" name="site_name">
 | 
			
		||||
      <FormItem>
 | 
			
		||||
        <FormLabel>Site Name</FormLabel>
 | 
			
		||||
@@ -126,22 +123,28 @@
 | 
			
		||||
      </FormItem>
 | 
			
		||||
    </FormField>
 | 
			
		||||
 | 
			
		||||
    <FormField name="allowed_file_upload_extensions" v-slot="{ componentField }">
 | 
			
		||||
      <FormItem>
 | 
			
		||||
        <FormLabel>Allowed file upload extensions</FormLabel>
 | 
			
		||||
        <FormControl>
 | 
			
		||||
          <TagsInput v-model="componentField.modelValue">
 | 
			
		||||
            <TagsInputItem v-for="item in componentField.modelValue" :key="item" :value="item">
 | 
			
		||||
              <TagsInputItemText />
 | 
			
		||||
              <TagsInputItemDelete />
 | 
			
		||||
            </TagsInputItem>
 | 
			
		||||
            <TagsInputInput placeholder="jpg" />
 | 
			
		||||
          </TagsInput>
 | 
			
		||||
        </FormControl>
 | 
			
		||||
        <FormDescription>Use `*` to allow any file.</FormDescription>
 | 
			
		||||
        <FormMessage />
 | 
			
		||||
      </FormItem>
 | 
			
		||||
    </FormField>
 | 
			
		||||
  
 | 
			
		||||
      <FormField name="allowed_file_upload_extensions" v-slot="{ componentField, handleChange }">
 | 
			
		||||
        <FormItem>
 | 
			
		||||
          <FormLabel>Allowed file upload extensions</FormLabel>
 | 
			
		||||
          <FormControl>
 | 
			
		||||
            <TagsInput
 | 
			
		||||
              :modelValue="componentField.modelValue"
 | 
			
		||||
              @update:modelValue="handleChange"
 | 
			
		||||
            >
 | 
			
		||||
              <TagsInputItem v-for="item in componentField.modelValue" :key="item" :value="item">
 | 
			
		||||
                <TagsInputItemText />
 | 
			
		||||
                <TagsInputItemDelete />
 | 
			
		||||
              </TagsInputItem>
 | 
			
		||||
              <TagsInputInput placeholder="jpg" />
 | 
			
		||||
            </TagsInput>
 | 
			
		||||
          </FormControl>
 | 
			
		||||
          <FormDescription>Use `*` to allow any file.</FormDescription>
 | 
			
		||||
          <FormMessage />
 | 
			
		||||
        </FormItem>
 | 
			
		||||
      </FormField>
 | 
			
		||||
    
 | 
			
		||||
 | 
			
		||||
    <Button type="submit" :isLoading="formLoading"> {{ submitLabel }} </Button>
 | 
			
		||||
  </form>
 | 
			
		||||
</template>
 | 
			
		||||
 
 | 
			
		||||
@@ -35,8 +35,8 @@ export const formSchema = z.object({
 | 
			
		||||
    .min(1, {
 | 
			
		||||
      message: 'Max upload file size must be at least 1 MB.'
 | 
			
		||||
    })
 | 
			
		||||
    .max(30, {
 | 
			
		||||
      message: 'Max upload file size cannot exceed 30 MB.'
 | 
			
		||||
    .max(500, {
 | 
			
		||||
      message: 'Max upload file size cannot exceed 500 MB.'
 | 
			
		||||
    }),
 | 
			
		||||
  allowed_file_upload_extensions: z.array(z.string()).nullable().default([]).optional()
 | 
			
		||||
})
 | 
			
		||||
 
 | 
			
		||||
@@ -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>
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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({
 | 
			
		||||
 
 | 
			
		||||
@@ -7,7 +7,9 @@
 | 
			
		||||
      </Button>
 | 
			
		||||
    </DropdownMenuTrigger>
 | 
			
		||||
    <DropdownMenuContent>
 | 
			
		||||
      <DropdownMenuItem @click="edit(props.role.id)">Edit</DropdownMenuItem>
 | 
			
		||||
      <DropdownMenuItem :as-child="true">
 | 
			
		||||
        <RouterLink :to="{ name: 'edit-sso', params: { id: props.role.id } }">Edit</RouterLink>
 | 
			
		||||
      </DropdownMenuItem>
 | 
			
		||||
      <DropdownMenuItem @click="() => (alertOpen = true)">Delete</DropdownMenuItem>
 | 
			
		||||
    </DropdownMenuContent>
 | 
			
		||||
  </DropdownMenu>
 | 
			
		||||
@@ -49,12 +51,10 @@ import {
 | 
			
		||||
  AlertDialogTitle
 | 
			
		||||
} from '@/components/ui/alert-dialog'
 | 
			
		||||
import { Button } from '@/components/ui/button'
 | 
			
		||||
import { useRouter } from 'vue-router'
 | 
			
		||||
import api from '@/api'
 | 
			
		||||
import { useEmitter } from '@/composables/useEmitter'
 | 
			
		||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
 | 
			
		||||
 | 
			
		||||
const router = useRouter()
 | 
			
		||||
const emit = useEmitter()
 | 
			
		||||
const alertOpen = ref(false)
 | 
			
		||||
 | 
			
		||||
@@ -68,10 +68,6 @@ const props = defineProps({
 | 
			
		||||
  }
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
function edit(id) {
 | 
			
		||||
  router.push({ path: `/admin/oidc/${id}/edit` })
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function handleDelete() {
 | 
			
		||||
  await api.deleteOIDC(props.role.id)
 | 
			
		||||
  alertOpen.value = false
 | 
			
		||||
 
 | 
			
		||||
@@ -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()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,47 +1,44 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div class="flex flex-col w-full">
 | 
			
		||||
  <div class="flex flex-col h-screen">
 | 
			
		||||
    <!-- Header -->
 | 
			
		||||
    <div class="p-2 border-b flex items-center justify-between">
 | 
			
		||||
      <div class="flex items-center space-x-3 pr-5">
 | 
			
		||||
        {{ conversationStore.currentContactName }}
 | 
			
		||||
    <div class="h-12 flex-shrink-0 px-2 border-b flex items-center justify-between">
 | 
			
		||||
      <div>
 | 
			
		||||
        <span v-if="!conversationStore.conversation.loading">
 | 
			
		||||
          {{ conversationStore.currentContactName }}
 | 
			
		||||
        </span>
 | 
			
		||||
        <Skeleton class="w-[130px] h-6" v-else />
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="flex items-center space-x-2">
 | 
			
		||||
        <div>
 | 
			
		||||
          <DropdownMenu>
 | 
			
		||||
            <DropdownMenuTrigger>
 | 
			
		||||
              <div
 | 
			
		||||
                class="flex items-center space-x-1 cursor-pointer bg-primary px-2 py-1 rounded-md text-sm"
 | 
			
		||||
              >
 | 
			
		||||
                <span
 | 
			
		||||
                  class="text-secondary font-medium inline-block"
 | 
			
		||||
                  v-if="conversationStore.current?.status"
 | 
			
		||||
                >
 | 
			
		||||
                  {{ conversationStore.current?.status }}
 | 
			
		||||
                </span>
 | 
			
		||||
                <span v-else class="text-secondary font-medium inline-block"> Loading... </span>
 | 
			
		||||
              </div>
 | 
			
		||||
            </DropdownMenuTrigger>
 | 
			
		||||
            <DropdownMenuContent>
 | 
			
		||||
              <DropdownMenuItem
 | 
			
		||||
                v-for="status in conversationStore.statusOptions"
 | 
			
		||||
                :key="status.value"
 | 
			
		||||
                @click="handleUpdateStatus(status.label)"
 | 
			
		||||
              >
 | 
			
		||||
                {{ status.label }}
 | 
			
		||||
              </DropdownMenuItem>
 | 
			
		||||
            </DropdownMenuContent>
 | 
			
		||||
          </DropdownMenu>
 | 
			
		||||
        </div>
 | 
			
		||||
      <div>
 | 
			
		||||
        <DropdownMenu>
 | 
			
		||||
          <DropdownMenuTrigger>
 | 
			
		||||
            <div
 | 
			
		||||
              class="flex items-center space-x-1 cursor-pointer bg-primary px-2 py-1 rounded-md text-sm"
 | 
			
		||||
              v-if="!conversationStore.conversation.loading"
 | 
			
		||||
            >
 | 
			
		||||
              <span class="text-secondary font-medium inline-block">
 | 
			
		||||
                {{ conversationStore.current?.status }}
 | 
			
		||||
              </span>
 | 
			
		||||
            </div>
 | 
			
		||||
            <Skeleton class="w-[70px] h-6 rounded-full" v-else />
 | 
			
		||||
          </DropdownMenuTrigger>
 | 
			
		||||
          <DropdownMenuContent>
 | 
			
		||||
            <DropdownMenuItem
 | 
			
		||||
              v-for="status in conversationStore.statusOptions"
 | 
			
		||||
              :key="status.value"
 | 
			
		||||
              @click="handleUpdateStatus(status.label)"
 | 
			
		||||
            >
 | 
			
		||||
              {{ status.label }}
 | 
			
		||||
            </DropdownMenuItem>
 | 
			
		||||
          </DropdownMenuContent>
 | 
			
		||||
        </DropdownMenu>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <!-- Messages & reply box -->
 | 
			
		||||
    <div>
 | 
			
		||||
      <div class="flex flex-col h-screen">
 | 
			
		||||
        <MessageList class="flex-1 overflow-y-auto" />
 | 
			
		||||
        <div class="sticky bottom-0">
 | 
			
		||||
          <ReplyBox class="h-max" />
 | 
			
		||||
        </div>
 | 
			
		||||
    <div class="flex flex-col flex-grow overflow-hidden">
 | 
			
		||||
      <MessageList class="flex-1 overflow-y-auto" />
 | 
			
		||||
      <div class="sticky bottom-0">
 | 
			
		||||
        <ReplyBox />
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
@@ -60,6 +57,7 @@ import ReplyBox from './ReplyBox.vue'
 | 
			
		||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
 | 
			
		||||
import { CONVERSATION_DEFAULT_STATUSES } from '@/constants/conversation'
 | 
			
		||||
import { useEmitter } from '@/composables/useEmitter'
 | 
			
		||||
import { Skeleton } from '@/components/ui/skeleton'
 | 
			
		||||
const conversationStore = useConversationStore()
 | 
			
		||||
const emitter = useEmitter()
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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,327 +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"
 | 
			
		||||
            @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"
 | 
			
		||||
          @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 cursorPosition = ref(0)
 | 
			
		||||
const selectedText = ref('')
 | 
			
		||||
const htmlContent = ref('')
 | 
			
		||||
const textContent = ref('')
 | 
			
		||||
const contentToSet = ref('')
 | 
			
		||||
const isBold = ref(false)
 | 
			
		||||
const isItalic = ref(false)
 | 
			
		||||
const isSending = 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()
 | 
			
		||||
@@ -335,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',
 | 
			
		||||
@@ -351,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
 | 
			
		||||
@@ -404,6 +294,7 @@ const handleFileUpload = (event) => {
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Inline image upload is not supported yet.
 | 
			
		||||
const handleInlineImageUpload = (event) => {
 | 
			
		||||
  for (const file of event.target.files) {
 | 
			
		||||
    api
 | 
			
		||||
@@ -413,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, {
 | 
			
		||||
@@ -430,42 +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)
 | 
			
		||||
 | 
			
		||||
@@ -493,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())
 | 
			
		||||
@@ -502,56 +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 {
 | 
			
		||||
    clearEditorContent.value = true
 | 
			
		||||
    conversationStore.resetMacro()
 | 
			
		||||
    conversationStore.resetMediaFiles()
 | 
			
		||||
    emailErrors.value = []
 | 
			
		||||
    nextTick(() => {
 | 
			
		||||
      clearEditorContent.value = false
 | 
			
		||||
    })
 | 
			
		||||
    // 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
 | 
			
		||||
  }
 | 
			
		||||
  // 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">Send</Button>
 | 
			
		||||
    <Button class="h-8 w-6 px-8" @click="handleSend" :disabled="!enableSend" :isLoading="isSending"
 | 
			
		||||
      >Send</Button
 | 
			
		||||
    >
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
@@ -52,11 +54,11 @@ 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,
 | 
			
		||||
  handleFileUpload: Function,
 | 
			
		||||
@@ -68,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 = () => {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,16 +1,15 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div class="h-screen flex flex-col">
 | 
			
		||||
    <!-- Header -->
 | 
			
		||||
    <header class="border-b">
 | 
			
		||||
      <div class="flex items-center space-x-4 p-2">
 | 
			
		||||
        <SidebarTrigger class="h-4 w-4" />
 | 
			
		||||
        <span class="text-xl font-semibold text-gray-800">{{ title }}</span>
 | 
			
		||||
      </div>
 | 
			
		||||
    </header>
 | 
			
		||||
    <div class="flex items-center space-x-4 px-2 h-12 border-b shrink-0">
 | 
			
		||||
      <SidebarTrigger class="h-4 w-4" />
 | 
			
		||||
      <span class="text-xl font-semibold text-gray-800">{{ title }}</span>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <!-- Filters -->
 | 
			
		||||
    <div class="bg-white px-2 py-2 flex justify-between items-center">
 | 
			
		||||
      <DropdownMenu>
 | 
			
		||||
    <div class="bg-white p-2 flex justify-between items-center">
 | 
			
		||||
      <!-- 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>
 | 
			
		||||
@@ -30,6 +29,9 @@
 | 
			
		||||
          </DropdownMenuItem>
 | 
			
		||||
        </DropdownMenuContent>
 | 
			
		||||
      </DropdownMenu>
 | 
			
		||||
      <div v-else></div>
 | 
			
		||||
 | 
			
		||||
      <!-- Sort dropdown-menu -->
 | 
			
		||||
      <DropdownMenu>
 | 
			
		||||
        <DropdownMenuTrigger asChild>
 | 
			
		||||
          <Button variant="ghost" class="w-30">
 | 
			
		||||
@@ -107,7 +109,7 @@
 | 
			
		||||
 | 
			
		||||
        <!-- Loading Skeleton -->
 | 
			
		||||
        <div v-if="isLoading" key="loading" class="space-y-4">
 | 
			
		||||
          <ConversationListItemSkeleton v-for="index in 10" :key="index" />
 | 
			
		||||
          <ConversationListItemSkeleton v-for="index in 5" :key="index" />
 | 
			
		||||
        </div>
 | 
			
		||||
      </TransitionGroup>
 | 
			
		||||
 | 
			
		||||
@@ -126,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,10 +39,14 @@
 | 
			
		||||
 | 
			
		||||
        <!-- Message preview and unread count -->
 | 
			
		||||
        <div class="flex items-start justify-between gap-2">
 | 
			
		||||
          <p class="text-sm text-gray-600 line-clamp-2 flex-1">
 | 
			
		||||
            <Reply class="inline-block w-4 h-4 mr-1.5 text-green-600 flex-shrink-0" />
 | 
			
		||||
          <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"
 | 
			
		||||
              v-if="conversation.last_message_sender === 'agent'"
 | 
			
		||||
            />
 | 
			
		||||
            {{ trimmedLastMessage }}
 | 
			
		||||
          </p>
 | 
			
		||||
          </div>
 | 
			
		||||
          <div
 | 
			
		||||
            v-if="conversation.unread_message_count > 0"
 | 
			
		||||
            class="flex items-center justify-center w-6 h-6 bg-green-600 text-white text-xs font-medium rounded-full"
 | 
			
		||||
@@ -53,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,18 +19,22 @@
 | 
			
		||||
        }"
 | 
			
		||||
      >
 | 
			
		||||
        <!-- 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" />
 | 
			
		||||
 | 
			
		||||
        <!-- Spinner for Pending Messages -->
 | 
			
		||||
        <Spinner v-if="message.status === 'pending'" size="w-4 h-4" class="mt-2" />
 | 
			
		||||
        <Spinner v-if="message.status === 'pending'" size="w-4 h-4" />
 | 
			
		||||
 | 
			
		||||
        <!-- Icons -->
 | 
			
		||||
        <div class="flex items-center space-x-2 mt-2">
 | 
			
		||||
          <Lock :size="10" v-if="isPrivateMessage" class="text-muted-foreground" />
 | 
			
		||||
          <CheckCheck :size="14" v-if="showCheckCheck" class="text-green-500" />
 | 
			
		||||
          <Check :size="14" v-if="showCheckCheck" class="text-green-500" />
 | 
			
		||||
          <RotateCcw
 | 
			
		||||
            size="10"
 | 
			
		||||
            @click="retryMessage(message)"
 | 
			
		||||
@@ -69,7 +73,7 @@
 | 
			
		||||
import { computed } from 'vue'
 | 
			
		||||
import { format } from 'date-fns'
 | 
			
		||||
import { useConversationStore } from '@/stores/conversation'
 | 
			
		||||
import { Lock, RotateCcw, CheckCheck } from 'lucide-vue-next'
 | 
			
		||||
import { Lock, RotateCcw, Check } from 'lucide-vue-next'
 | 
			
		||||
import { revertCIDToImageSrc } from '@/utils/strings'
 | 
			
		||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
 | 
			
		||||
import { Spinner } from '@/components/ui/spinner'
 | 
			
		||||
@@ -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 }"
 | 
			
		||||
        />
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,7 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div class="flex flex-col relative h-full">
 | 
			
		||||
    <div ref="threadEl" class="flex-1 overflow-y-auto" @scroll="handleScroll">
 | 
			
		||||
      <div class="min-h-full pb-20 px-4">
 | 
			
		||||
      <div class="min-h-full px-4 pb-10">
 | 
			
		||||
        <div
 | 
			
		||||
          class="text-center mt-3"
 | 
			
		||||
          v-if="
 | 
			
		||||
@@ -20,16 +20,16 @@
 | 
			
		||||
          </Button>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <TransitionGroup
 | 
			
		||||
          enter-active-class="animate-slide-in"
 | 
			
		||||
          tag="div"
 | 
			
		||||
          class="space-y-4"
 | 
			
		||||
          v-if="!conversationStore.messages.loading"
 | 
			
		||||
        >
 | 
			
		||||
        <MessagesSkeleton :count="10" v-if="conversationStore.messages.loading" />
 | 
			
		||||
 | 
			
		||||
        <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'" />
 | 
			
		||||
@@ -43,7 +43,6 @@
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </TransitionGroup>
 | 
			
		||||
        <MessagesSkeleton :count="20" v-else />
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
@@ -56,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"
 | 
			
		||||
 
 | 
			
		||||
@@ -1,17 +1,13 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div class="flex flex-wrap gap-2 px-2 py-1">
 | 
			
		||||
  <div class="flex flex-wrap">
 | 
			
		||||
    <TransitionGroup name="attachment-list" tag="div" class="flex flex-wrap gap-2">
 | 
			
		||||
      <div
 | 
			
		||||
        v-for="attachment in allAttachments"
 | 
			
		||||
        :key="attachment.uuid || attachment.tempId"
 | 
			
		||||
        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 px-2 gap-2"
 | 
			
		||||
      >
 | 
			
		||||
        <div class="flex items-center space-x-2 px-3 py-2">
 | 
			
		||||
          <span v-if="attachment.loading" class="dot-loader">
 | 
			
		||||
            <span class="dot"></span>
 | 
			
		||||
            <span class="dot"></span>
 | 
			
		||||
            <span class="dot"></span>
 | 
			
		||||
          </span>
 | 
			
		||||
        <div class="flex items-center space-x-1 py-1">
 | 
			
		||||
          <DotLoader v-if="attachment.loading"/>
 | 
			
		||||
          <PaperclipIcon v-else size="16" class="text-gray-500 group-hover:text-primary" />
 | 
			
		||||
 | 
			
		||||
          <Tooltip>
 | 
			
		||||
@@ -20,22 +16,21 @@
 | 
			
		||||
                class="max-w-[12rem] overflow-hidden text-ellipsis whitespace-nowrap text-sm font-medium text-primary group-hover:text-gray-900"
 | 
			
		||||
              >
 | 
			
		||||
                {{ getAttachmentName(attachment.filename) }}
 | 
			
		||||
                <span class="text-xs text-gray-500 ml-1">
 | 
			
		||||
                  {{ formatBytes(attachment.size) }}
 | 
			
		||||
                </span>
 | 
			
		||||
              </div>
 | 
			
		||||
            </TooltipTrigger>
 | 
			
		||||
            <TooltipContent>
 | 
			
		||||
              <p class="text-sm">{{ attachment.filename }}</p>
 | 
			
		||||
            </TooltipContent>
 | 
			
		||||
          </Tooltip>
 | 
			
		||||
 | 
			
		||||
          <span class="text-xs text-gray-500">
 | 
			
		||||
            {{ formatBytes(attachment.size) }}
 | 
			
		||||
          </span>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <button
 | 
			
		||||
          v-if="!attachment.loading"
 | 
			
		||||
          @click.stop="onDelete(attachment.uuid)"
 | 
			
		||||
          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="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 attachment"
 | 
			
		||||
        >
 | 
			
		||||
          <X size="14" />
 | 
			
		||||
@@ -49,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({
 | 
			
		||||
@@ -85,13 +81,13 @@ const getAttachmentName = (name) => {
 | 
			
		||||
.attachment-list-move,
 | 
			
		||||
.attachment-list-enter-active,
 | 
			
		||||
.attachment-list-leave-active {
 | 
			
		||||
  transition: all 0.5s ease;
 | 
			
		||||
  transition: all 0.3s ease;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.attachment-list-enter-from,
 | 
			
		||||
.attachment-list-leave-to {
 | 
			
		||||
  opacity: 0;
 | 
			
		||||
  transform: translateX(30px);
 | 
			
		||||
  transform: translateX(10px);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.attachment-list-leave-active {
 | 
			
		||||
 
 | 
			
		||||
@@ -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>
 | 
			
		||||
 
 | 
			
		||||
@@ -16,33 +16,32 @@
 | 
			
		||||
        size="16"
 | 
			
		||||
      />
 | 
			
		||||
    </div>
 | 
			
		||||
    <div>
 | 
			
		||||
      <h4 class="mt-3">
 | 
			
		||||
        <span v-if="conversationStore.conversation.loading">
 | 
			
		||||
          <Skeleton class="w-32 h-4" />
 | 
			
		||||
        </span>
 | 
			
		||||
        <span v-else>
 | 
			
		||||
          {{ conversation?.contact?.first_name + ' ' + conversation?.contact?.last_name }}
 | 
			
		||||
        </span>
 | 
			
		||||
      </h4>
 | 
			
		||||
      <p class="text-sm text-muted-foreground flex gap-2 mt-1">
 | 
			
		||||
        <Mail class="size-3 mt-1" />
 | 
			
		||||
        <span v-if="conversationStore.conversation.loading">
 | 
			
		||||
          <Skeleton class="w-32 h-4" />
 | 
			
		||||
        </span>
 | 
			
		||||
        <span v-else>
 | 
			
		||||
          {{ conversation?.contact?.email }}
 | 
			
		||||
        </span>
 | 
			
		||||
      </p>
 | 
			
		||||
      <p class="text-sm text-muted-foreground flex gap-2 mt-1">
 | 
			
		||||
        <Phone class="size-3 mt-1" />
 | 
			
		||||
        <span v-if="conversationStore.conversation.loading">
 | 
			
		||||
          <Skeleton class="w-32 h-4" />
 | 
			
		||||
        </span>
 | 
			
		||||
        <span v-else>
 | 
			
		||||
          {{ conversation?.contact?.phone_number || 'Not available' }}
 | 
			
		||||
        </span>
 | 
			
		||||
      </p>
 | 
			
		||||
 | 
			
		||||
    <div class="mt-3 h-6">
 | 
			
		||||
      <span v-if="conversationStore.conversation.loading">
 | 
			
		||||
        <Skeleton class="w-24 h-4" />
 | 
			
		||||
      </span>
 | 
			
		||||
      <span v-else>
 | 
			
		||||
        {{ conversation?.contact?.first_name + ' ' + conversation?.contact?.last_name }}
 | 
			
		||||
      </span>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class="text-sm text-muted-foreground flex gap-2 h-4 mt-2">
 | 
			
		||||
      <Mail class="size-3 mt-1" />
 | 
			
		||||
      <span v-if="conversationStore.conversation.loading">
 | 
			
		||||
        <Skeleton class="w-32 h-4" />
 | 
			
		||||
      </span>
 | 
			
		||||
      <span v-else>
 | 
			
		||||
        {{ conversation?.contact?.email }}
 | 
			
		||||
      </span>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class="text-sm text-muted-foreground flex gap-2 mt-2 h-4">
 | 
			
		||||
      <Phone class="size-3 mt-1" />
 | 
			
		||||
      <span v-if="conversationStore.conversation.loading">
 | 
			
		||||
        <Skeleton class="w-32 h-4" />
 | 
			
		||||
      </span>
 | 
			
		||||
      <span v-else>
 | 
			
		||||
        {{ conversation?.contact?.phone_number || 'Not available' }}
 | 
			
		||||
      </span>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
 
 | 
			
		||||
@@ -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,5 +1,5 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div class="flex items-center space-x-4 p-4">
 | 
			
		||||
  <div class="flex items-center space-x-4 px-2 h-12">
 | 
			
		||||
    <SidebarTrigger class="cursor-pointer w-4 h-4 text-black" />
 | 
			
		||||
    <div class="flex-1 flex items-center">
 | 
			
		||||
      <Search class="w-5 h-5" />
 | 
			
		||||
 
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user