Compare commits

..

74 Commits

Author SHA1 Message Date
Abhinav Raut
71601364ae fix: mark conversation as read when messages are already cached 2025-02-26 17:41:25 +05:30
Abhinav Raut
44723fb70d fix: update command to start backend dev server in documentation 2025-02-26 12:11:15 +05:30
Abhinav Raut
67e1230485 feat: agent availability status
New columns in users table to store user availability status.

Websocket pings sets the last active at timestamp, once user stops sending pings (on disconnect) after 5 minutes the user availalbility status changes to offline.

Detects auto away by checking for mouse, keyboard events and sets user status to away.

User can also set their status to away manually from the sidebar.

Migrations for v0.3.0

Minor visual fixes.

Bump version in package.json
2025-02-26 04:34:30 +05:30
Abhinav Raut
d58898c60f fix: update DockerHub image path and branch reference in installation documentation 2025-02-26 00:52:42 +05:30
Abhinav Raut
a8dc0a6242 fix: correct DockerHub image path in installation documentation 2025-02-26 00:50:30 +05:30
Abhinav Raut
3aa144f703 feat: display app update component only for admin routes. 2025-02-25 18:27:21 +05:30
Abhinav Raut
fcbd16f042 feat: implement app update checker and UI notification 2025-02-25 02:49:09 +05:30
Abhinav Raut
e8f3f24422 feat: update build configuration and versioning system 2025-02-25 01:35:52 +05:30
Abhinav Raut
425bb4ed04 feat: add database upgrade functionality (adapted from listmonk) 2025-02-25 01:35:27 +05:30
Abhinav Raut
0c3da82250 fix: remove redundant margin class from Actions AccordionItem 2025-02-25 00:34:28 +05:30
Abhinav Raut
8649826a89 Update .gitattributes 2025-02-25 00:19:06 +05:30
Abhinav Raut
d427dfd20c Update .gitattributes 2025-02-25 00:18:42 +05:30
Abhinav Raut
afb54c371b Update .gitattributes 2025-02-25 00:16:28 +05:30
Abhinav Raut
46459599c7 Update .gitattributes 2025-02-25 00:10:14 +05:30
Abhinav Raut
63a6aedfd0 chore: update .gitattributes to specify Go language for all files 2025-02-25 00:08:04 +05:30
Abhinav Raut
ffbf613e68 chore: add .gitattributes to mark frontend files as vendored 2025-02-25 00:06:28 +05:30
Abhinav Raut
88f82fe80b fix: typos in docker image and curl request. 2025-02-24 22:28:01 +05:30
Abhinav Raut
914b6371b6 fix: goreleaser 2025-02-24 22:10:01 +05:30
Abhinav Raut
89eb05f337 feat: disallow edits for admin role
- chore: minor layout fixes
- chore: adds doc strings to handlers
2025-02-24 22:07:24 +05:30
Abhinav Raut
71a3588855 refactor: update help text for clarity across various admin views 2025-02-24 21:46:57 +05:30
Abhinav Raut
c6baf3f9bf fix: increase z-index for popper content wrapper to ensure visibility 2025-02-24 21:12:14 +05:30
Abhinav Raut
368ec3c82b fix: extend timeout for closing WebSocket connection on no pong response 2025-02-24 21:12:14 +05:30
Abhinav Raut
4cc40ec5d5 Update README.md 2025-02-24 03:41:58 +05:30
Abhinav Raut
171e404e6f chore: update screenshot URL in documentation 2025-02-24 03:40:53 +05:30
Abhinav Raut
28f4fda274 Update README.md 2025-02-24 03:31:30 +05:30
Abhinav Raut
00ded9c19b Update README.md 2025-02-24 03:30:28 +05:30
Abhinav Raut
17efaf0f2c fix: update password strength requirements and hint for system users 2025-02-24 02:42:27 +05:30
Abhinav Raut
b44290a6f0 feat: add GitHub Actions workflow for MkDocs deployment
- fix: inject correct vars in go releaser builds
2025-02-24 02:27:06 +05:30
Abhinav Raut
1a7ee4d8c6 chore: clean and remove unused UI components and update DotLoader usage 2025-02-23 22:40:09 +05:30
Abhinav Raut
ab56d01e22 chore: remove unused ConversationActions and DashboardGreet components 2025-02-23 21:50:43 +05:30
Abhinav Raut
4e729b91ef fix: typo in sample toml filename. 2025-02-23 15:19:29 +05:30
Abhinav Raut
edd629276d fix: typo in config sample toml file 2025-02-23 15:08:48 +05:30
Abhinav Raut
94e9f0f3de chore: update GoReleaser configuration for ARM and standard builds 2025-02-23 14:55:56 +05:30
Abhinav Raut
29798c9ba0 chore: remove CHANGELOG.md from GoReleaser archive files 2025-02-23 14:26:32 +05:30
Abhinav Raut
cadf26c8b5 chore: set main directory for ARM build in GoReleaser configuration 2025-02-23 14:14:38 +05:30
Abhinav Raut
8358455478 chore: specify main directory for standard build in GoReleaser configuration 2025-02-23 14:05:49 +05:30
Abhinav Raut
5d38747bdd build: ensure dependencies are installed before building frontend 2025-02-23 14:00:15 +05:30
Abhinav Raut
5f3b0c3415 ci: update Node.js version to 18.12 in release workflow 2025-02-23 13:56:20 +05:30
Abhinav Raut
13f0d2003c ci: add Node.js setup and pnpm installation to release workflow 2025-02-23 13:55:07 +05:30
Abhinav Raut
afc2ff45df chore: fix formatting in GoReleaser configuration for archives 2025-02-23 13:51:29 +05:30
Abhinav Raut
605c0aa7a1 chore: remove version declaration from GoReleaser configuration 2025-02-23 13:49:00 +05:30
Abhinav Raut
5da727350b ci: update GoReleaser action to use latest version and improve release args 2025-02-23 13:41:50 +05:30
Abhinav Raut
ef077aeac8 ci: update GoReleaser action in release workflow 2025-02-23 13:33:27 +05:30
Abhinav Raut
2558f97f0a ci: update Docker organization in GitHub Actions workflow 2025-02-23 12:55:09 +05:30
Abhinav Raut
501027a0b2 chore: remove docker-entrypoint.sh from goreleaser configuration 2025-02-23 12:43:14 +05:30
Abhinav Raut
cc38d8825d ci: add GitHub Actions workflow for tag-based release and DockerHub push 2025-02-23 12:41:41 +05:30
Abhinav Raut
5361bcb24f feat: dockerize libredesk
- feat: new flag for idempotent installation `--idempotent-install`
- feat: new flag to skip yes/no prompt `--yes`
- feat: new flag for upgrades `--upgrade`
- refactor: update doc strings and sample toml file.
- chore: update .gitignore.
2025-02-22 23:33:18 +05:30
Abhinav Raut
730740094f fix[ui]: handle empty dashboard charts. 2025-02-22 23:33:18 +05:30
Abhinav Raut
49761960fd fix[UI]: improve attachment preview layout and transition effects 2025-02-22 23:33:18 +05:30
Abhinav Raut
41c6ebe003 chore: update sample toml set max body size to 500MB 2025-02-22 23:33:18 +05:30
Abhinav Raut
2ae85ac76a Update README.md 2025-02-21 22:44:14 +05:30
Abhinav Raut
1a7f53628b fix[UI]: General setting form tag input not propogating updates to form.
- chore: set allowed file upload size to 500 mb in zod schema.
2025-02-21 22:16:20 +05:30
Abhinav Raut
0649633878 fix[ui]: remove unncessary margin to spinner in agent message bubble.
- chore[ui]: update icon for message sent
2025-02-21 21:43:11 +05:30
Abhinav Raut
d2a79d9a10 feat: store last message sender in conversation as this will be used to show the Reply icon in the conversations list.
- Introduces new column `last_message_sender` in conversations table.
- Changes to propogate this new column in websocket updates.
2025-02-21 21:40:57 +05:30
Abhinav Raut
aba849d344 fix: router path
fix: breadcrumb url
2025-02-21 20:49:09 +05:30
Abhinav Raut
3cb584c4d6 fix: add missing loader for business hours form button 2025-02-21 20:45:16 +05:30
Abhinav Raut
8567baa0e1 chore: remove 'In Progress' and 'Waiting' from default statuses
As an admin can always add custom statuses and expand upon the existing ones, the `In Progress` and `Waiting` statuses are not required.
2025-02-21 20:40:32 +05:30
Abhinav Raut
b601724b0a fix[admin]: replace route paths with route names. 2025-02-21 20:34:15 +05:30
Abhinav Raut
01c136c469 fix: handle unlimited limit for max auto assigned conversations too user. 2025-02-21 20:34:15 +05:30
Abhinav Raut
a8c61074bb fix: loader state for conversation text editor send button 2025-02-21 20:34:15 +05:30
Abhinav Raut
6324651d01 fix[sidebar]: make sidebar background darker. 2025-02-21 20:34:15 +05:30
Abhinav Raut
62e38814c7 feat: API to discover OIDC provider
- fix: discover oidc provider first before attempting to create one.
2025-02-21 20:34:15 +05:30
Abhinav Raut
7eb365c04a Update README.md 2025-02-21 00:20:09 +05:30
Abhinav Raut
83460ab6a3 refactor: layout fixes 2025-02-21 00:18:43 +05:30
Abhinav Raut
1e44bbbde5 Update README.md 2025-02-20 23:23:25 +05:30
Abhinav Raut
1f70884628 Update README.md 2025-02-20 14:57:59 +05:30
Abhinav Raut
f5a4813830 Update README.md 2025-02-20 13:54:11 +05:30
Abhinav Raut
a2e320473d Update README.md 2025-02-20 13:52:49 +05:30
Abhinav Raut
2c8900ed95 Update README.md 2025-02-20 13:51:37 +05:30
Abhinav Raut
2d4356e4f5 Merge pull request #5 from abhinavxd/dependabot/go_modules/golang.org/x/net-0.33.0
chore(deps): bump golang.org/x/net from 0.27.0 to 0.33.0
2025-02-20 12:29:49 +05:30
dependabot[bot]
dbb2ae303f chore(deps): bump golang.org/x/net from 0.27.0 to 0.33.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.27.0 to 0.33.0.
- [Commits](https://github.com/golang/net/compare/v0.27.0...v0.33.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-20 06:58:24 +00:00
Abhinav Raut
67a7427ab0 Update README.md 2025-02-20 12:26:32 +05:30
Abhinav Raut
8392371ebf Merge branch 'mvp' 2025-02-20 12:25:36 +05:30
Abhinav Raut
b8e38424d5 Update README.md 2025-02-14 14:20:33 +05:30
137 changed files with 1940 additions and 2675 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
VERSION export-subst

31
.github/workflows/github-pages.yml vendored Normal file
View 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
View 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
View File

@@ -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
View 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
View 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"]

View File

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

View File

@@ -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
![Screenshot_20250220_231723](https://github.com/user-attachments/assets/55e0ec68-b624-4442-8387-6157742da253)
#### 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.app/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.

2
VERSION Normal file
View File

@@ -0,0 +1,2 @@
$Format:%h$
$Format:%D$

View File

@@ -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"))
@@ -98,6 +99,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"))

View File

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

View File

@@ -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 := strings.TrimSpace(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

View File

@@ -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,20 @@ 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)
}
// Set user availability status to online.
if err := app.user.UpdateAvailability(user.ID, umodels.Online); err != nil {
return sendErrorEnvelope(r, err)
}
user.AvailabilityStatus = umodels.Online
if err := app.auth.SaveSession(amodels.User{
ID: user.ID,
Email: user.Email.String,

View File

@@ -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*24, app)
}
// Wait for shutdown signal.
<-ctx.Done()
colorlog.Red("Shutting down HTTP server...")

View File

@@ -43,9 +43,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)

View File

@@ -44,6 +44,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 +65,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 +93,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 +104,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")
}

View File

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

View File

@@ -20,7 +20,15 @@ func handleGetGeneralSettings(r *fastglue.Request) error {
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(out)
// Unmarshal to add the app.update to the settings.
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))
}
// Add the app.update to the settings, adding `app` prefix to the key to match the settings structure in db.
settings["app.update"] = app.update
return r.SendEnvelope(settings)
}
// handleUpdateGeneralSettings updates general settings.

98
cmd/updates.go Normal file
View 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 15 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 * 15)
fnCheck()
// Thereafter, check every $interval.
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
fnCheck()
}
}

148
cmd/upgrade.go Normal file
View File

@@ -0,0 +1,148 @@
// 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},
}
// 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)
}

View File

@@ -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, "")
@@ -66,6 +64,19 @@ func handleGetUser(r *fastglue.Request) error {
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 (
@@ -228,7 +239,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.")

View File

@@ -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,6 +33,7 @@ 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"
@@ -44,6 +46,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

62
docker-compose.yml Normal file
View 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:

View 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
View 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/Screenshot_20250220_231723-VxuEQgEiFfI9xhzJDOvgMK0yJ0TwR3.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
View 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
View 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
View 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

View File

@@ -1,8 +0,0 @@
{
"recommendations": [
"Vue.volar",
"Vue.vscode-typescript-vue-plugin",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode"
]
}

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "libredesk",
"version": "0.0.0",
"version": "0.3.0",
"private": true,
"type": "module",
"scripts": {
@@ -18,41 +18,29 @@
"@formkit/auto-animate": "^0.8.2",
"@internationalized/date": "^3.5.5",
"@radix-icons/vue": "^1.0.0",
"@tailwindcss/typography": "^0.5.10",
"@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-i18n": "9",
@@ -68,7 +56,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 +66,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"

1640
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -47,8 +47,16 @@
@edit-view="editView"
@delete-view="deleteView"
>
<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>
@@ -73,8 +81,10 @@ 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'
@@ -109,6 +119,8 @@ const view = ref({})
const openCreateViewForm = ref(false)
initWS()
useIdleDetection()
onMounted(() => {
initToaster()
listenViewRefresh()
@@ -117,8 +129,10 @@ onMounted(() => {
// initialize data stores
const initStores = async () => {
if (!userStore.userID) {
await userStore.getCurrentUser()
}
await Promise.allSettled([
userStore.getCurrentUser(),
getUserViews(),
conversationStore.fetchStatuses(),
conversationStore.fetchPriorities(),

View File

@@ -90,6 +90,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,6 +169,7 @@ 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)
@@ -322,6 +324,7 @@ export default {
uploadMedia,
updateAssigneeLastSeen,
updateUser,
updateCurrentUserAvailability,
updateAutomationRule,
updateAutomationRuleWeights,
updateAutomationRulesExecutionMode,
@@ -344,6 +347,7 @@ export default {
getAllEnabledOIDC,
getOIDC,
updateOIDC,
testOIDC,
deleteOIDC,
getTemplate,
getTemplates,

View File

@@ -6,13 +6,6 @@
font-size: 16px;
}
.page-content {
height: 100vh;
overflow-y: scroll;
padding-bottom: 100px;
background-color: #fff;
}
@layer base {
html,
body {
@@ -32,66 +25,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 +231,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 +312,7 @@ a[data-active='false']:hover {
opacity: 1;
}
}
[data-radix-popper-content-wrapper] {
z-index: 9999 !important;
}

View File

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

View File

@@ -1,82 +1,93 @@
<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="Abhinav" />
<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>
<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>
</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'
const userStore = useUserStore()
const logout = () => {
window.location.href = '/logout'
window.location.href = '/logout'
}
</script>
</script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -0,0 +1,43 @@
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { useUserStore } from '@/stores/user'
import { debounce } from '@/utils/debounce'
export function useIdleDetection () {
const userStore = useUserStore()
// 4 minutes
const AWAY_THRESHOLD = 4 * 60 * 1000
// 1 minute
const CHECK_INTERVAL = 60 * 1000
const lastActivity = ref(Date.now())
const timer = ref(null)
function resetTimer () {
if (userStore.user.availability_status === 'away' || userStore.user.availability_status === 'offline') {
userStore.updateUserAvailability('online', false)
}
lastActivity.value = Date.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)
clearInterval(timer.value)
})
}

View File

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

View File

@@ -112,7 +112,7 @@ export const adminNavItems = [
children: [
{
title: 'SSO',
href: '/admin/oidc',
href: '/admin/sso',
permission: 'oidc:manage'
}
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 class="h-max" />
</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()

View File

@@ -120,6 +120,7 @@
:handleInlineImageUpload="handleInlineImageUpload"
:isBold="isBold"
:isItalic="isItalic"
:isSending="isSending"
@toggleBold="toggleBold"
@toggleItalic="toggleItalic"
:enableSend="enableSend"
@@ -240,6 +241,7 @@
:handleInlineImageUpload="handleInlineImageUpload"
:isBold="isBold"
:isItalic="isItalic"
:isSending="isSending"
@toggleBold="toggleBold"
@toggleItalic="toggleItalic"
:enableSend="enableSend"
@@ -276,6 +278,7 @@ const insertContent = ref(null)
const setInlineImage = ref(null)
const clearEditorContent = ref(false)
const isEditorFullscreen = ref(false)
const isSending = ref(false)
const cursorPosition = ref(0)
const selectedText = ref('')
const htmlContent = ref('')
@@ -464,6 +467,8 @@ const handleSend = async () => {
isEditorFullscreen.value = false
try {
isSending.value = true
// Send message if there is text content in the editor.
if (hasTextContent.value) {
// Replace inline image url with cid.
@@ -517,6 +522,7 @@ const handleSend = async () => {
description: handleHTTPError(error).message
})
} finally {
isSending.value = false
clearEditorContent.value = true
conversationStore.resetMacro()
conversationStore.resetMediaFiles()

View File

@@ -35,7 +35,7 @@
<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>
@@ -57,6 +57,7 @@ const emit = defineEmits(['toggleBold', 'toggleItalic', 'emojiSelect'])
defineProps({
isBold: Boolean,
isItalic: Boolean,
isSending: Boolean,
enableSend: Boolean,
handleSend: Function,
handleFileUpload: Function,

View File

@@ -1,15 +1,13 @@
<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">
<div class="bg-white p-2 flex justify-between items-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" class="w-30">
@@ -107,7 +105,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 +124,9 @@
<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>

View File

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

View File

@@ -25,12 +25,12 @@
<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 +69,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'

View File

@@ -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,11 +20,13 @@
</Button>
</div>
<MessagesSkeleton :count="10" v-if="conversationStore.messages.loading" />
<TransitionGroup
v-else
enter-active-class="animate-slide-in"
tag="div"
class="space-y-4"
v-if="!conversationStore.messages.loading"
>
<div
v-for="message in conversationStore.conversationMessages"
@@ -43,7 +45,6 @@
</div>
</div>
</TransitionGroup>
<MessagesSkeleton :count="20" v-else />
</div>
</div>

View File

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

View File

@@ -1,5 +0,0 @@
<template>
hi
</template>
<script setup></script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,7 @@
<template>
<div class="space-y-4 md:block page-content">
<div class="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
<div class="flex-1 lg:max-w-3xl min-h-[700px]">
<div class="space-y-6">
<router-view />
</div>
</div>
<div class="overflow-y-auto h-screen">
<div class="p-6 sm:p-8 min-h-full flex flex-col">
<router-view class="flex-grow" />
</div>
</div>
</template>
<script setup></script>

View File

@@ -1,7 +1,7 @@
<template>
<div class="page-content">
<div class="p-6 sm:p-8">
<router-view />
<div class="overflow-y-auto h-screen">
<div class="p-6 sm:p-8 min-h-full flex flex-col">
<router-view class="flex-grow" />
</div>
</div>
</template>
</template>

View File

@@ -1,9 +1,9 @@
<template>
<div class="flex justify-between">
<div class="flex justify-between h-full">
<div class="w-8/12">
<slot name="content" />
</div>
<div class="rounded-lg w-3/12 p-2 space-y-2 h-max-content">
<div class="rounded-lg w-3/12 p-2 space-y-2 self-start">
<slot name="help" />
</div>
</div>

View File

@@ -1,6 +1,7 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { createI18n } from 'vue-i18n'
import { useAppSettingsStore } from './stores/appSettings'
import router from './router'
import mitt from 'mitt'
import api from './api'
@@ -38,12 +39,16 @@ async function initApp () {
const i18n = createI18n(i18nConfig)
const app = createApp(Root)
const pinia = createPinia()
app.use(pinia)
// Store app settings in Pinia
const settingsStore = useAppSettingsStore()
settingsStore.setSettings(settings)
// Add emitter to global properties.
app.config.globalProperties.emitter = emitter
app.use(router)
app.use(pinia)
app.use(i18n)
app.mount('#app')
}

View File

@@ -174,6 +174,7 @@ const routes = [
children: [
{
path: '',
name: 'business-hours-list',
component: () => import('@/views/admin/business-hours/BusinessHoursList.vue'),
},
{
@@ -198,16 +199,19 @@ const routes = [
children: [
{
path: '',
name: 'sla-list',
component: () => import('@/views/admin/sla/SLAList.vue'),
},
{
path: 'new',
name: 'new-sla',
component: () => import('@/views/admin/sla/CreateEditSLA.vue'),
meta: { title: 'New SLA' }
},
{
path: ':id/edit',
props: true,
name: 'edit-sla',
component: () => import('@/views/admin/sla/CreateEditSLA.vue'),
meta: { title: 'Edit SLA' }
},
@@ -220,6 +224,7 @@ const routes = [
children: [
{
path: '',
name: 'inbox-list',
component: () => import('@/views/admin/inbox/InboxList.vue'),
},
{
@@ -253,6 +258,7 @@ const routes = [
children: [
{
path: '',
name: 'user-list',
component: () => import('@/views/admin/users/UserList.vue'),
},
{
@@ -276,16 +282,19 @@ const routes = [
children: [
{
path: '',
name: 'team-list',
component: () => import('@/views/admin/teams/TeamList.vue'),
},
{
path: 'new',
name: 'new-team',
component: () => import('@/views/admin/teams/CreateTeamForm.vue'),
meta: { title: 'Create Team' }
},
{
path: ':id/edit',
props: true,
name: 'edit-team',
component: () => import('@/views/admin/teams/EditTeamForm.vue'),
meta: { title: 'Edit Team' }
},
@@ -298,16 +307,19 @@ const routes = [
children: [
{
path: '',
name: 'role-list',
component: () => import('@/views/admin/roles/RoleList.vue'),
},
{
path: 'new',
name: 'new-role',
component: () => import('@/views/admin/roles/NewRole.vue'),
meta: { title: 'Create Role' }
},
{
path: ':id/edit',
props: true,
name: 'edit-role',
component: () => import('@/views/admin/roles/EditRole.vue'),
meta: { title: 'Edit Role' }
}
@@ -318,17 +330,20 @@ const routes = [
{
path: 'automations',
component: () => import('@/views/admin/automations/Automation.vue'),
name: 'automations',
meta: { title: 'Automations' },
children: [
{
path: 'new',
props: true,
name: 'new-automation',
component: () => import('@/views/admin/automations/CreateOrEditRule.vue'),
meta: { title: 'Create Automation' }
},
{
path: ':id/edit',
props: true,
name: 'edit-automation',
component: () => import('@/views/admin/automations/CreateOrEditRule.vue'),
meta: { title: 'Edit Automation' }
}
@@ -337,6 +352,7 @@ const routes = [
{
path: 'templates',
component: () => import('@/views/admin/templates/Templates.vue'),
name: 'templates',
meta: { title: 'Templates' },
children: [
{
@@ -356,22 +372,26 @@ const routes = [
]
},
{
path: 'oidc',
path: 'sso',
component: () => import('@/views/admin/oidc/OIDC.vue'),
name: 'sso',
meta: { title: 'SSO' },
children: [
{
path: '',
name: 'sso-list',
component: () => import('@/views/admin/oidc/OIDCList.vue'),
},
{
path: ':id/edit',
props: true,
name: 'edit-sso',
component: () => import('@/views/admin/oidc/CreateEditOIDC.vue'),
meta: { title: 'Edit SSO' }
},
{
path: 'new',
name: 'new-sso',
component: () => import('@/views/admin/oidc/CreateEditOIDC.vue'),
meta: { title: 'New SSO' }
}
@@ -392,12 +412,13 @@ const routes = [
meta: { title: 'Statuses' }
},
{
path: 'Macros',
path: 'macros',
component: () => import('@/views/admin/macros/Macros.vue'),
meta: { title: 'Macros' },
children: [
{
path: '',
name: 'macro-list',
component: () => import('@/views/admin/macros/MacroList.vue'),
},
{

View File

@@ -0,0 +1,12 @@
import { defineStore } from 'pinia'
export const useAppSettingsStore = defineStore('settings', {
state: () => ({
settings: {}
}),
actions: {
setSettings (newSettings) {
this.settings = newSettings
}
}
})

View File

@@ -282,8 +282,10 @@ export const useConversationStore = defineStore('conversation', () => {
async function fetchMessages (uuid, fetchNextPage = false) {
// Messages are already cached?
let hasMessages = messages.data.getAllPagesMessages(uuid)
if (hasMessages.length > 0 && !fetchNextPage)
if (hasMessages.length > 0 && !fetchNextPage) {
markConversationAsRead(uuid)
return
}
// Fetch messages from server.
messages.loading = true
@@ -293,7 +295,6 @@ export const useConversationStore = defineStore('conversation', () => {
const response = await api.getConversationMessages(uuid, { page: page, page_size: MESSAGE_LIST_PAGE_SIZE })
const result = response.data?.data || {}
const newMessages = result.results || []
// Mark conversation as read
markConversationAsRead(uuid)
// Cache messages
messages.data.addMessages(uuid, newMessages, result.page, result.total_pages)
@@ -545,10 +546,12 @@ export const useConversationStore = defineStore('conversation', () => {
if (listConversation) {
listConversation.last_message = message.content
listConversation.last_message_at = message.created_at
listConversation.last_message_sender = message.sender_type
if (listConversation.uuid !== conversation?.data?.uuid) {
listConversation.unread_message_count += 1
}
} else {
// Conversation is not in the list, fetch the first page of the conversations list as this updated conversation might be at the top.
fetchFirstPageConversations()
}
}

View File

@@ -15,14 +15,15 @@ export const useUserStore = defineStore('user', () => {
avatar_url: '',
email: '',
teams: [],
permissions: []
permissions: [],
availability_status: 'offline'
})
const emitter = useEmitter()
const userID = computed(() => user.value.id)
const firstName = computed(() => user.value.first_name)
const lastName = computed(() => user.value.last_name)
const avatar = computed(() => user.value.avatar_url)
const firstName = computed(() => user.value.first_name || '')
const lastName = computed(() => user.value.last_name || '')
const avatar = computed(() => user.value.avatar_url || '')
const permissions = computed(() => user.value.permissions || [])
const email = computed(() => user.value.email)
const teams = computed(() => user.value.teams || [])
@@ -71,6 +72,10 @@ export const useUserStore = defineStore('user', () => {
}
}
const setCurrentUser = (userData) => {
user.value = userData
}
const setAvatar = (avatarURL) => {
if (typeof avatarURL !== 'string') {
console.warn('Avatar URL must be a string')
@@ -83,6 +88,16 @@ export const useUserStore = defineStore('user', () => {
user.value.avatar_url = ''
}
const updateUserAvailability = async (status, isManual = true) => {
try {
const apiStatus = status === 'away' && isManual ? 'away_manual' : status
await api.updateCurrentUserAvailability({ status: apiStatus })
user.value.availability_status = apiStatus
} catch (error) {
if (error?.response?.status === 401) window.location.href = '/'
}
}
return {
user,
userID,
@@ -96,9 +111,11 @@ export const useUserStore = defineStore('user', () => {
getInitials,
hasAdminTabPermissions,
hasReportTabPermissions,
setCurrentUser,
getCurrentUser,
clearAvatar,
setAvatar,
updateUserAvailability,
can
}
})
})

View File

@@ -0,0 +1,7 @@
export function debounce (fn, delay) {
let timeout
return function (...args) {
clearTimeout(timeout)
timeout = setTimeout(() => fn(...args), delay)
}
}

View File

@@ -1,12 +1,12 @@
<template>
<div class="p-5 w-screen h-screen">
<div class="h-full">
<div class="flex flex-col space-y-5">
<div class="space-y-1">
<span class="sub-title">Public avatar</span>
<p class="text-muted-foreground text-xs">Change your avatar here.</p>
</div>
<div class="flex space-x-5">
<Avatar class="size-28 bg-white">
<Avatar class="size-28">
<AvatarImage :src="userStore.avatar" alt="Cropped Image" />
<AvatarFallback>{{ userStore.getInitials }}</AvatarFallback>
</Avatar>

View File

@@ -1,7 +1,7 @@
<template>
<AdminPageWithHelp>
<template #content>
<div v-if="router.currentRoute.value.path === '/admin/automations'">
<div v-if="router.currentRoute.value.name === 'automations'">
<div class="flex justify-between mb-5">
<div class="ml-auto">
<Button @click="newRule">New rule</Button>
@@ -34,6 +34,6 @@ import AdminPageWithHelp from '@/layouts/admin/AdminPageWithHelp.vue'
const router = useRouter()
const selectedTab = useStorage('automationsTab', 'new_conversation')
const newRule = () => {
router.push({ path: `/admin/automations/new`, query: { type: selectedTab.value } })
router.push({ name: 'new-automation', query: { type: selectedTab.value } })
}
</script>

View File

@@ -235,8 +235,8 @@ const isNewForm = computed(() => {
})
const breadcrumbLinks = [
{ path: '/admin/automations', label: 'Automations' },
{ path: '#', label: breadcrumbPageLabel() }
{ path: 'automations', label: 'Automations' },
{ path: '', label: breadcrumbPageLabel() }
]
const firstRuleGroup = ref([])
@@ -330,7 +330,7 @@ const handleSave = async (values) => {
await api.updateAutomationRule(props.id, updatedRule)
} else {
await api.createAutomationRule(updatedRule)
router.push('/admin/automations')
router.push({ name: 'automations' })
}
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Success',

View File

@@ -45,7 +45,7 @@ const submitForm = async (values) => {
title: 'Success',
description: 'Business hours created successfully',
})
router.push('/admin/business-hours')
router.push({ name: 'business-hours-list' })
}
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
@@ -67,8 +67,8 @@ const isNewForm = computed(() => {
})
const breadcrumbLinks = [
{ path: '/admin/business-hours', label: 'Business hours' },
{ path: '#', label: breadCrumLabel() }
{ path: 'business-hours-list', label: 'Business hours' },
{ path: '', label: breadCrumLabel() }
]
onMounted(async () => {

View File

@@ -11,10 +11,7 @@
</div>
</template>
<template #help>
<p>
Configure core helpdesk settings like helpdesk name, timezone, business hours, and more.
</p>
<p>These settings affect your entire helpdesk system.</p>
<p>General settings for your support desk like timezone, working hours, etc.</p>
</template>
</AdminPageWithHelp>
</template>

View File

@@ -21,8 +21,8 @@ const formLoading = ref(false)
const isLoading = ref(false)
const inbox = ref({})
const breadcrumbLinks = [
{ path: '/admin/inboxes', label: 'Inboxes' },
{ path: '#', label: 'Edit Inbox' }
{ path: 'inbox-list', label: 'Inboxes' },
{ path: '', label: 'Edit Inbox' }
]
const submitForm = (values) => {
@@ -59,7 +59,7 @@ const updateInbox = async (payload) => {
})
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Could not update inbox',
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})

View File

@@ -82,8 +82,8 @@ const currentStep = ref(1)
const selectedChannel = ref(null)
const router = useRouter()
const breadcrumbLinks = [
{ path: '/admin/inboxes', label: 'Inboxes' },
{ path: '#', label: 'New Inbox' }
{ path: 'inbox-list', label: 'Inboxes' },
{ path: '', label: 'New Inbox' }
]
@@ -149,7 +149,7 @@ async function createInbox (payload) {
title: 'Success',
description: 'Inbox created successfully'
})
router.push('/admin/inboxes')
router.push({ name: 'inbox-list' })
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Could not create inbox',

View File

@@ -22,8 +22,8 @@ const router = useRouter()
const emit = useEmitter()
const formLoading = ref(false)
const breadcrumbLinks = [
{ path: '/admin/conversations/macros', label: 'Macros' },
{ path: '#', label: 'New macro' }
{ path: 'macro-list', label: 'Macros' },
{ path: '', label: 'New macro' }
]
const onSubmit = (values) => {
@@ -38,7 +38,7 @@ const createMacro = async (values) => {
title: 'Success',
description: 'Macro created successfully'
})
router.push('/admin/conversations/macros')
router.push({ name: 'macro-list' })
} catch (error) {
emit.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',

View File

@@ -22,8 +22,8 @@ const formLoading = ref(false)
const emitter = useEmitter()
const breadcrumbLinks = [
{ path: '/admin/conversations/macros', label: 'Macros' },
{ path: '#', label: 'Edit macro' }
{ path: 'macro-list', label: 'Macros' },
{ path: '', label: 'Edit macro' }
]
const submitForm = (values) => {

View File

@@ -5,7 +5,6 @@
</template>
<template #help>
<p>Combine multiple conversation actions into single-click macros.</p>
<p>Create reusable action sets for common agent responses.</p>
</template>
</AdminPageWithHelp>
</template>

View File

@@ -38,8 +38,23 @@ const props = defineProps({
})
const submitForm = async (values) => {
// Test the provider first.
try {
formLoading.value = true
await api.testOIDC({
provider_url: values.provider_url
})
} catch (error) {
formLoading.value = false
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})
return
}
try {
let toastDescription = ''
if (props.id) {
if (values.client_secret.includes('•')) {
@@ -47,10 +62,10 @@ const submitForm = async (values) => {
}
await api.updateOIDC(props.id, values)
toastDescription = 'Provider updated successfully'
router.push({ name: 'sso-list' })
} else {
await api.createOIDC(values)
toastDescription = 'Provider created successfully'
router.push('/admin/oidc')
}
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Success',
@@ -84,8 +99,8 @@ const isNewForm = computed(() => {
})
const breadcrumbLinks = [
{ path: '/admin/oidc', label: 'OIDC' },
{ path: '#', label: breadCrumLabel() }
{ path: 'sso-list', label: 'SSO' },
{ path: '', label: breadCrumLabel() }
]
onMounted(async () => {

View File

@@ -1,11 +1,11 @@
<template>
<AdminPageWithHelp>
<template #content>
<router-view></router-view>
<router-view/>
</template>
<template #help>
<p>Configure single sign-on with one or multiple OpenID Connect providers.</p>
<p>Configure single sign-on with one or more OpenID Connect providers.</p>
</template>
</AdminPageWithHelp>
</template>

View File

@@ -4,7 +4,9 @@
<div class="flex justify-between mb-5">
<div></div>
<div>
<Button @click="navigateToNewOIDC">New OIDC</Button>
<RouterLink :to="{ name: 'new-sso' }">
<Button>New SSO</Button>
</RouterLink>
</div>
</div>
<div>
@@ -18,7 +20,6 @@ import { ref, onMounted, onUnmounted } from 'vue'
import DataTable from '@/components/datatable/DataTable.vue'
import { columns } from '@/features/admin/oidc/dataTableColumns.js'
import { Button } from '@/components/ui/button'
import { useRouter } from 'vue-router'
import { useEmitter } from '@/composables/useEmitter'
import { Spinner } from '@/components/ui/spinner'
@@ -27,7 +28,6 @@ import api from '@/api'
const oidc = ref([])
const isLoading = ref(false)
const router = useRouter()
const emit = useEmitter()
onMounted(() => {
@@ -52,8 +52,4 @@ const fetchAll = async () => {
isLoading.value = false
}
}
const navigateToNewOIDC = () => {
router.push('/admin/oidc/new')
}
</script>

View File

@@ -46,8 +46,8 @@ onMounted(async () => {
const breadcrumbLinks = [
{ path: '/admin/teams/roles', label: 'Roles' },
{ path: '#', label: 'Edit role' }
{ path: 'role-list', label: 'Roles' },
{ path: '', label: 'Edit role' }
]
const submitForm = async (values) => {

View File

@@ -19,8 +19,8 @@ const emitter = useEmitter()
const router = useRouter()
const formLoading = ref(false)
const breadcrumbLinks = [
{ path: '/admin/teams/roles', label: 'Roles' },
{ path: '#', label: 'Add role' }
{ path: 'role-list', label: 'Roles' },
{ path: '', label: 'New role' }
]
const submitForm = async (values) => {
@@ -31,7 +31,7 @@ const submitForm = async (values) => {
title: 'Success',
description: 'Role created successfully'
})
router.push('/admin/teams/roles')
router.push({ name: 'role-list' })
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Could not create role',

View File

@@ -2,7 +2,9 @@
<Spinner v-if="isLoading" />
<div :class="{ 'transition-opacity duration-300 opacity-50': isLoading }">
<div class="flex justify-end mb-5">
<Button @click="navigateToNewRole"> New role </Button>
<router-link :to="{ name: 'new-role' }">
<Button> New role </Button>
</router-link>
</div>
<div>
<DataTable :columns="columns" :data="roles" />
@@ -16,14 +18,12 @@ import { columns } from '@/features/admin/roles/dataTableColumns.js'
import { Button } from '@/components/ui/button'
import DataTable from '@/components/datatable/DataTable.vue'
import { handleHTTPError } from '@/utils/http'
import { useRouter } from 'vue-router'
import { Spinner } from '@/components/ui/spinner'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import api from '@/api'
const emitter = useEmitter()
const router = useRouter()
const roles = ref([])
const isLoading = ref(false)
@@ -49,8 +49,4 @@ onMounted(async () => {
if (data?.model === 'team') getRoles()
})
})
const navigateToNewRole = () => {
router.push('/admin/teams/roles/new')
}
</script>

View File

@@ -5,7 +5,7 @@
</template>
<template #help>
<p>Manage roles and their permissions.</p>
<p>Manage roles and their permissions for fine-grained control over your support desk.</p>
</template>
</AdminPageWithHelp>
</template>

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