mirror of
https://github.com/abhinavxd/libredesk.git
synced 2025-11-13 18:36:03 +00:00
Compare commits
74 Commits
mvp
...
v0.3.1-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71601364ae | ||
|
|
44723fb70d | ||
|
|
67e1230485 | ||
|
|
d58898c60f | ||
|
|
a8dc0a6242 | ||
|
|
3aa144f703 | ||
|
|
fcbd16f042 | ||
|
|
e8f3f24422 | ||
|
|
425bb4ed04 | ||
|
|
0c3da82250 | ||
|
|
8649826a89 | ||
|
|
d427dfd20c | ||
|
|
afb54c371b | ||
|
|
46459599c7 | ||
|
|
63a6aedfd0 | ||
|
|
ffbf613e68 | ||
|
|
88f82fe80b | ||
|
|
914b6371b6 | ||
|
|
89eb05f337 | ||
|
|
71a3588855 | ||
|
|
c6baf3f9bf | ||
|
|
368ec3c82b | ||
|
|
4cc40ec5d5 | ||
|
|
171e404e6f | ||
|
|
28f4fda274 | ||
|
|
00ded9c19b | ||
|
|
17efaf0f2c | ||
|
|
b44290a6f0 | ||
|
|
1a7ee4d8c6 | ||
|
|
ab56d01e22 | ||
|
|
4e729b91ef | ||
|
|
edd629276d | ||
|
|
94e9f0f3de | ||
|
|
29798c9ba0 | ||
|
|
cadf26c8b5 | ||
|
|
8358455478 | ||
|
|
5d38747bdd | ||
|
|
5f3b0c3415 | ||
|
|
13f0d2003c | ||
|
|
afc2ff45df | ||
|
|
605c0aa7a1 | ||
|
|
5da727350b | ||
|
|
ef077aeac8 | ||
|
|
2558f97f0a | ||
|
|
501027a0b2 | ||
|
|
cc38d8825d | ||
|
|
5361bcb24f | ||
|
|
730740094f | ||
|
|
49761960fd | ||
|
|
41c6ebe003 | ||
|
|
2ae85ac76a | ||
|
|
1a7f53628b | ||
|
|
0649633878 | ||
|
|
d2a79d9a10 | ||
|
|
aba849d344 | ||
|
|
3cb584c4d6 | ||
|
|
8567baa0e1 | ||
|
|
b601724b0a | ||
|
|
01c136c469 | ||
|
|
a8c61074bb | ||
|
|
6324651d01 | ||
|
|
62e38814c7 | ||
|
|
7eb365c04a | ||
|
|
83460ab6a3 | ||
|
|
1e44bbbde5 | ||
|
|
1f70884628 | ||
|
|
f5a4813830 | ||
|
|
a2e320473d | ||
|
|
2c8900ed95 | ||
|
|
2d4356e4f5 | ||
|
|
dbb2ae303f | ||
|
|
67a7427ab0 | ||
|
|
8392371ebf | ||
|
|
b8e38424d5 |
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
VERSION export-subst
|
||||||
31
.github/workflows/github-pages.yml
vendored
Normal file
31
.github/workflows/github-pages.yml
vendored
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
name: Deploy MkDocs
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v4
|
||||||
|
with:
|
||||||
|
python-version: 3.x
|
||||||
|
|
||||||
|
- run: pip install mkdocs-material
|
||||||
|
|
||||||
|
- run: |
|
||||||
|
if [ -f requirements.txt ]; then
|
||||||
|
pip install -r requirements.txt;
|
||||||
|
fi
|
||||||
|
|
||||||
|
- run: cd docs && mkdocs build
|
||||||
|
|
||||||
|
- name: Deploy to GitHub Pages
|
||||||
|
uses: peaceiris/actions-gh-pages@v3
|
||||||
|
with:
|
||||||
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
publish_dir: ./docs/site
|
||||||
62
.github/workflows/release.yml
vendored
Normal file
62
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
with:
|
||||||
|
driver: docker-container
|
||||||
|
cache-to: type=gha
|
||||||
|
cache-from: type=gha
|
||||||
|
|
||||||
|
- name: Log in to Docker Hub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Log in to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: "1.21"
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: '18.12'
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
run: npm install -g pnpm
|
||||||
|
|
||||||
|
- name: Run GoReleaser
|
||||||
|
uses: goreleaser/goreleaser-action@v5
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
args: release --parallelism 1 --clean
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
DOCKER_ORG: libredesk
|
||||||
|
GITHUB_ORG: ${{ github.repository_owner }}
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,5 +1,10 @@
|
|||||||
node_modules
|
node_modules
|
||||||
config.toml
|
config.toml
|
||||||
|
config.toml.*
|
||||||
libredesk.bin
|
libredesk.bin
|
||||||
uploads/*
|
libredesk
|
||||||
|
libredesk.exe
|
||||||
|
uploads
|
||||||
.env
|
.env
|
||||||
|
dist/
|
||||||
|
.vscode/
|
||||||
|
|||||||
182
.goreleaser.yaml
Normal file
182
.goreleaser.yaml
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
env:
|
||||||
|
- GO111MODULE=on
|
||||||
|
- CGO_ENABLED=0
|
||||||
|
- GITHUB_ORG=abhinavxd
|
||||||
|
- DOCKER_ORG=libredesk
|
||||||
|
|
||||||
|
before:
|
||||||
|
hooks:
|
||||||
|
- go mod tidy
|
||||||
|
- make frontend-build
|
||||||
|
|
||||||
|
builds:
|
||||||
|
- id: "universal"
|
||||||
|
main: ./cmd
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=0
|
||||||
|
goos:
|
||||||
|
- darwin
|
||||||
|
- freebsd
|
||||||
|
- linux
|
||||||
|
- netbsd
|
||||||
|
- openbsd
|
||||||
|
- windows
|
||||||
|
goarch:
|
||||||
|
- amd64
|
||||||
|
- arm64
|
||||||
|
- arm
|
||||||
|
goarm:
|
||||||
|
- 6
|
||||||
|
- 7
|
||||||
|
binary: 'libredesk{{ if eq .Os "windows" }}.exe{{ end }}'
|
||||||
|
ldflags:
|
||||||
|
- -s -w -X "main.buildString={{ .Tag }} ({{ .ShortCommit }} {{ .Date }}, {{ .Os }}/{{ .Arch }})" -X "main.versionString={{ .Tag }}"
|
||||||
|
hooks:
|
||||||
|
post: make stuff BIN={{ .Path }}
|
||||||
|
|
||||||
|
archives:
|
||||||
|
- format: tar.gz
|
||||||
|
name_template: 'libredesk_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if eq .Arch "arm" }}v{{ .Arm }}{{ end }}'
|
||||||
|
files:
|
||||||
|
- README.md
|
||||||
|
- LICENSE
|
||||||
|
|
||||||
|
checksum:
|
||||||
|
name_template: "libredesk_{{ .Version }}_checksums.txt"
|
||||||
|
|
||||||
|
source:
|
||||||
|
enabled: true
|
||||||
|
format: tar.gz
|
||||||
|
name_template: "libredesk_{{ .Version }}_source"
|
||||||
|
|
||||||
|
dockers:
|
||||||
|
- use: buildx
|
||||||
|
goos: linux
|
||||||
|
goarch: amd64
|
||||||
|
ids:
|
||||||
|
- universal
|
||||||
|
image_templates:
|
||||||
|
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-amd64"
|
||||||
|
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-amd64"
|
||||||
|
- "ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:latest-amd64"
|
||||||
|
- "ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:{{ .Tag }}-amd64"
|
||||||
|
build_flag_templates:
|
||||||
|
- --platform=linux/amd64
|
||||||
|
- --label=org.opencontainers.image.title={{ .ProjectName }}
|
||||||
|
- --label=org.opencontainers.image.description={{ .ProjectName }}
|
||||||
|
- --label=org.opencontainers.image.url=https://github.com/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}
|
||||||
|
- --label=org.opencontainers.image.source=https://github.com/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}
|
||||||
|
- --label=org.opencontainers.image.version={{ .Version }}
|
||||||
|
- --label=org.opencontainers.image.created={{ time "2006-01-02T15:04:05Z07:00" }}
|
||||||
|
- --label=org.opencontainers.image.revision={{ .FullCommit }}
|
||||||
|
- --label=org.opencontainers.image.licenses=AGPL-3.0
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
extra_files:
|
||||||
|
- config.sample.toml
|
||||||
|
|
||||||
|
- use: buildx
|
||||||
|
goos: linux
|
||||||
|
goarch: arm64
|
||||||
|
ids:
|
||||||
|
- universal
|
||||||
|
image_templates:
|
||||||
|
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-arm64"
|
||||||
|
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-arm64"
|
||||||
|
- "ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:latest-arm64"
|
||||||
|
- "ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:{{ .Tag }}-arm64"
|
||||||
|
build_flag_templates:
|
||||||
|
- --platform=linux/arm64
|
||||||
|
- --label=org.opencontainers.image.title={{ .ProjectName }}
|
||||||
|
- --label=org.opencontainers.image.description={{ .ProjectName }}
|
||||||
|
- --label=org.opencontainers.image.url=https://github.com/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}
|
||||||
|
- --label=org.opencontainers.image.source=https://github.com/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}
|
||||||
|
- --label=org.opencontainers.image.version={{ .Version }}
|
||||||
|
- --label=org.opencontainers.image.created={{ time "2006-01-02T15:04:05Z07:00" }}
|
||||||
|
- --label=org.opencontainers.image.revision={{ .FullCommit }}
|
||||||
|
- --label=org.opencontainers.image.licenses=AGPL-3.0
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
extra_files:
|
||||||
|
- config.sample.toml
|
||||||
|
|
||||||
|
- use: buildx
|
||||||
|
goos: linux
|
||||||
|
goarch: arm
|
||||||
|
goarm: 6
|
||||||
|
ids:
|
||||||
|
- universal
|
||||||
|
image_templates:
|
||||||
|
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-armv6"
|
||||||
|
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv6"
|
||||||
|
- "ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:latest-armv6"
|
||||||
|
- "ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv6"
|
||||||
|
build_flag_templates:
|
||||||
|
- --platform=linux/arm/v6
|
||||||
|
- --label=org.opencontainers.image.title={{ .ProjectName }}
|
||||||
|
- --label=org.opencontainers.image.description={{ .ProjectName }}
|
||||||
|
- --label=org.opencontainers.image.url=https://github.com/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}
|
||||||
|
- --label=org.opencontainers.image.source=https://github.com/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}
|
||||||
|
- --label=org.opencontainers.image.version={{ .Version }}
|
||||||
|
- --label=org.opencontainers.image.created={{ time "2006-01-02T15:04:05Z07:00" }}
|
||||||
|
- --label=org.opencontainers.image.revision={{ .FullCommit }}
|
||||||
|
- --label=org.opencontainers.image.licenses=AGPL-3.0
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
extra_files:
|
||||||
|
- config.sample.toml
|
||||||
|
|
||||||
|
- use: buildx
|
||||||
|
goos: linux
|
||||||
|
goarch: arm
|
||||||
|
goarm: 7
|
||||||
|
ids:
|
||||||
|
- universal
|
||||||
|
image_templates:
|
||||||
|
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-armv7"
|
||||||
|
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv7"
|
||||||
|
- "ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:latest-armv7"
|
||||||
|
- "ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv7"
|
||||||
|
build_flag_templates:
|
||||||
|
- --platform=linux/arm/v7
|
||||||
|
- --label=org.opencontainers.image.title={{ .ProjectName }}
|
||||||
|
- --label=org.opencontainers.image.description={{ .ProjectName }}
|
||||||
|
- --label=org.opencontainers.image.url=https://github.com/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}
|
||||||
|
- --label=org.opencontainers.image.source=https://github.com/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}
|
||||||
|
- --label=org.opencontainers.image.version={{ .Version }}
|
||||||
|
- --label=org.opencontainers.image.created={{ time "2006-01-02T15:04:05Z07:00" }}
|
||||||
|
- --label=org.opencontainers.image.revision={{ .FullCommit }}
|
||||||
|
- --label=org.opencontainers.image.licenses=AGPL-3.0
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
extra_files:
|
||||||
|
- config.sample.toml
|
||||||
|
|
||||||
|
docker_manifests:
|
||||||
|
- name_template: "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest"
|
||||||
|
image_templates:
|
||||||
|
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-amd64"
|
||||||
|
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-arm64"
|
||||||
|
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-armv6"
|
||||||
|
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-armv7"
|
||||||
|
- name_template: "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}"
|
||||||
|
image_templates:
|
||||||
|
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-amd64"
|
||||||
|
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-arm64"
|
||||||
|
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv6"
|
||||||
|
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv7"
|
||||||
|
- name_template: ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:latest
|
||||||
|
image_templates:
|
||||||
|
- ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:latest-amd64
|
||||||
|
- ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:latest-arm64
|
||||||
|
- ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:latest-armv6
|
||||||
|
- ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:latest-armv7
|
||||||
|
- name_template: ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:{{ .Tag }}
|
||||||
|
image_templates:
|
||||||
|
- ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:{{ .Tag }}-amd64
|
||||||
|
- ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:{{ .Tag }}-arm64
|
||||||
|
- ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv6
|
||||||
|
- ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv7
|
||||||
|
|
||||||
|
release:
|
||||||
|
github:
|
||||||
|
owner: abhinavxd
|
||||||
|
name: libredesk
|
||||||
|
prerelease: auto
|
||||||
|
draft: true
|
||||||
18
Dockerfile
Normal file
18
Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Use the latest version of Alpine Linux as the base image
|
||||||
|
FROM alpine:latest
|
||||||
|
|
||||||
|
# Install necessary packages
|
||||||
|
RUN apk --no-cache add ca-certificates
|
||||||
|
|
||||||
|
# Set the working directory to /libredesk
|
||||||
|
WORKDIR /libredesk
|
||||||
|
|
||||||
|
# Copy necessary files
|
||||||
|
COPY libredesk .
|
||||||
|
COPY config.sample.toml config.toml
|
||||||
|
|
||||||
|
# Expose port 9000 for the application
|
||||||
|
EXPOSE 9000
|
||||||
|
|
||||||
|
# Set the default command to run the libredesk binary
|
||||||
|
CMD ["./libredesk"]
|
||||||
34
Makefile
34
Makefile
@@ -1,11 +1,13 @@
|
|||||||
# Build variables
|
# Try to get the commit hash from 1) git 2) the VERSION file 3) fallback.
|
||||||
LAST_COMMIT := $(shell git rev-parse --short HEAD)
|
LAST_COMMIT := $(or $(shell git rev-parse --short HEAD 2> /dev/null),$(shell head -n 1 VERSION | grep -oP -m 1 "^[a-z0-9]+$$"), "")
|
||||||
LAST_COMMIT_DATE := $(shell git show -s --format=%ci ${LAST_COMMIT})
|
|
||||||
VERSION := $(shell git describe --tags)
|
# Try to get the semver from 1) git 2) the VERSION file 3) fallback.
|
||||||
BUILDSTR := ${VERSION} (Commit: ${LAST_COMMIT_DATE} (${LAST_COMMIT}), Build: $(shell date +"%Y-%m-%d %H:%M:%S %z"))
|
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
|
# Binary names and paths
|
||||||
BIN_LIBREDESK := libredesk.bin
|
BIN := libredesk
|
||||||
FRONTEND_DIR := frontend
|
FRONTEND_DIR := frontend
|
||||||
FRONTEND_DIST := ${FRONTEND_DIR}/dist
|
FRONTEND_DIST := ${FRONTEND_DIR}/dist
|
||||||
STATIC := ${FRONTEND_DIST} i18n schema.sql static
|
STATIC := ${FRONTEND_DIST} i18n schema.sql static
|
||||||
@@ -28,15 +30,15 @@ install-deps: $(STUFFBIN)
|
|||||||
|
|
||||||
# Build the frontend for production.
|
# Build the frontend for production.
|
||||||
.PHONY: frontend-build
|
.PHONY: frontend-build
|
||||||
frontend-build:
|
frontend-build: install-deps
|
||||||
@echo "→ Building frontend for production..."
|
@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.
|
# Run the Go backend server in development mode.
|
||||||
.PHONY: run-backend
|
.PHONY: run-backend
|
||||||
run-backend:
|
run-backend:
|
||||||
@echo "→ Running 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.
|
# Run the JS frontend server in development mode.
|
||||||
.PHONY: run-frontend
|
.PHONY: run-frontend
|
||||||
@@ -44,26 +46,26 @@ run-frontend:
|
|||||||
@echo "→ Installing frontend dependencies (if not already installed)..."
|
@echo "→ Installing frontend dependencies (if not already installed)..."
|
||||||
@cd ${FRONTEND_DIR} && pnpm install
|
@cd ${FRONTEND_DIR} && pnpm install
|
||||||
@echo "→ Running frontend..."
|
@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.
|
# Build the backend binary.
|
||||||
.PHONY: backend-build
|
.PHONY: build-backend
|
||||||
backend-build: $(STUFFBIN)
|
build-backend: $(STUFFBIN)
|
||||||
@echo "→ Building backend..."
|
@echo "→ Building backend..."
|
||||||
@CGO_ENABLED=0 go build -a\
|
@CGO_ENABLED=0 go build -a\
|
||||||
-ldflags="-X 'main.buildString=${BUILDSTR}' -X 'main.buildDate=${LAST_COMMIT_DATE}' -s -w" \
|
-ldflags="-X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}' -s -w" \
|
||||||
-o ${BIN_LIBREDESK} cmd/*.go
|
-o ${BIN} cmd/*.go
|
||||||
|
|
||||||
# Main build target: builds both frontend and backend, then stuffs static assets into the binary.
|
# Main build target: builds both frontend and backend, then stuffs static assets into the binary.
|
||||||
.PHONY: build
|
.PHONY: build
|
||||||
build: frontend-build backend-build stuff
|
build: frontend-build build-backend stuff
|
||||||
@echo "→ Build successful. Current version: $(VERSION)"
|
@echo "→ Build successful. Current version: $(VERSION)"
|
||||||
|
|
||||||
# Stuff static assets into the binary using stuffbin.
|
# Stuff static assets into the binary using stuffbin.
|
||||||
.PHONY: stuff
|
.PHONY: stuff
|
||||||
stuff: $(STUFFBIN)
|
stuff: $(STUFFBIN)
|
||||||
@echo "→ Stuffing static assets into binary..."
|
@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.
|
# Build the application in demo mode.
|
||||||
.PHONY: demo-build
|
.PHONY: demo-build
|
||||||
|
|||||||
91
README.md
91
README.md
@@ -1,39 +1,82 @@
|
|||||||
|
<a href="https://zerodha.tech"><img src="https://zerodha.tech/static/images/github-badge.svg" align="right" alt="Zerodha Tech Badge" /></a>
|
||||||
|
|
||||||
|
|
||||||
# Libredesk
|
# Libredesk
|
||||||
|
|
||||||
Open-source, self-hosted customer support desk. Single binary app.
|
Open source, self-hosted customer support desk. Single binary app.
|
||||||
|
|
||||||
> This project is currently in **alpha**. Features and APIs may change and are not yet fully tested.
|
Visit [libredesk.io](https://libredesk.io) for more info. Check out the [**Live demo**](https://demo.libredesk.io/).
|
||||||
|
|
||||||
## Developer Setup
|

|
||||||
|
|
||||||
#### Prerequisites
|
|
||||||
|
|
||||||
- **go**
|
> **CAUTION:** This project is currently in **alpha**. Features and APIs may change and are not yet fully tested.
|
||||||
- **pnpm**
|
|
||||||
- **PostgreSQL >= 13**
|
|
||||||
- **Redis**
|
|
||||||
|
|
||||||
1. **Clone the repository**:
|
## Features
|
||||||
|
|
||||||
```bash
|
- **Multi Inbox**
|
||||||
git clone https://github.com/abhinavxd/libredesk.git
|
Libredesk supports multiple inboxes, letting you manage conversations across teams effortlessly.
|
||||||
cd libredesk
|
- **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
|
## Installation
|
||||||
cp config.toml.sample config.toml
|
|
||||||
```
|
|
||||||
- Edit the `config.toml` file to configure your database and Redis connection settings.
|
|
||||||
|
|
||||||
3. **Run in Development Mode**:
|
### Docker
|
||||||
|
|
||||||
- Backend: `make run-backend`
|
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)
|
||||||
- Frontend: `make run-frontend`
|
|
||||||
|
|
||||||
---
|
```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
|
||||||
|
|
||||||
Visit [libredesk.io](https://libredesk.io) for more info.
|
# 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.
|
||||||
|
|||||||
@@ -37,8 +37,9 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
|
|||||||
// OpenID connect single sign-on.
|
// OpenID connect single sign-on.
|
||||||
g.GET("/api/v1/oidc/enabled", handleGetAllEnabledOIDC)
|
g.GET("/api/v1/oidc/enabled", handleGetAllEnabledOIDC)
|
||||||
g.GET("/api/v1/oidc", perm(handleGetAllOIDC, "oidc:manage"))
|
g.GET("/api/v1/oidc", perm(handleGetAllOIDC, "oidc:manage"))
|
||||||
g.GET("/api/v1/oidc/{id}", perm(handleGetOIDC, "oidc:manage"))
|
|
||||||
g.POST("/api/v1/oidc", perm(handleCreateOIDC, "oidc:manage"))
|
g.POST("/api/v1/oidc", perm(handleCreateOIDC, "oidc:manage"))
|
||||||
|
g.POST("/api/v1/oidc/test", perm(handleTestOIDC, "oidc:manage"))
|
||||||
|
g.GET("/api/v1/oidc/{id}", perm(handleGetOIDC, "oidc:manage"))
|
||||||
g.PUT("/api/v1/oidc/{id}", perm(handleUpdateOIDC, "oidc:manage"))
|
g.PUT("/api/v1/oidc/{id}", perm(handleUpdateOIDC, "oidc:manage"))
|
||||||
g.DELETE("/api/v1/oidc/{id}", perm(handleDeleteOIDC, "oidc:manage"))
|
g.DELETE("/api/v1/oidc/{id}", perm(handleDeleteOIDC, "oidc:manage"))
|
||||||
|
|
||||||
@@ -98,6 +99,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
|
|||||||
g.GET("/api/v1/users/me", auth(handleGetCurrentUser))
|
g.GET("/api/v1/users/me", auth(handleGetCurrentUser))
|
||||||
g.PUT("/api/v1/users/me", auth(handleUpdateCurrentUser))
|
g.PUT("/api/v1/users/me", auth(handleUpdateCurrentUser))
|
||||||
g.GET("/api/v1/users/me/teams", auth(handleGetCurrentUserTeams))
|
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.DELETE("/api/v1/users/me/avatar", auth(handleDeleteAvatar))
|
||||||
g.GET("/api/v1/users/compact", auth(handleGetUsersCompact))
|
g.GET("/api/v1/users/compact", auth(handleGetUsersCompact))
|
||||||
g.GET("/api/v1/users", perm(handleGetUsers, "users:manage"))
|
g.GET("/api/v1/users", perm(handleGetUsers, "users:manage"))
|
||||||
|
|||||||
10
cmd/init.go
10
cmd/init.go
@@ -98,6 +98,9 @@ func initFlags() {
|
|||||||
"path to one or more config files (will be merged in order)")
|
"path to one or more config files (will be merged in order)")
|
||||||
f.Bool("version", false, "show current version of the build")
|
f.Bool("version", false, "show current version of the build")
|
||||||
f.Bool("install", false, "setup database")
|
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")
|
f.Bool("set-system-user-password", false, "set password for the system user")
|
||||||
|
|
||||||
if err := f.Parse(os.Args[1:]); err != nil {
|
if err := f.Parse(os.Args[1:]); err != nil {
|
||||||
@@ -305,6 +308,11 @@ func initCSAT(db *sqlx.DB) *csat.Manager {
|
|||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// initWS inits websocket hub.
|
||||||
|
func initWS(user *user.Manager) *ws.Hub {
|
||||||
|
return ws.NewHub(user)
|
||||||
|
}
|
||||||
|
|
||||||
// initTemplates inits template manager.
|
// initTemplates inits template manager.
|
||||||
func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts *constants) *tmpl.Manager {
|
func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts *constants) *tmpl.Manager {
|
||||||
var (
|
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)
|
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
|
return inbox, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,23 +4,38 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/abhinavxd/libredesk/internal/colorlog"
|
||||||
|
"github.com/abhinavxd/libredesk/internal/dbutil"
|
||||||
"github.com/abhinavxd/libredesk/internal/user"
|
"github.com/abhinavxd/libredesk/internal/user"
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
"github.com/knadh/stuffbin"
|
"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.
|
// 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 {
|
// idempotent install skips the installation if the database schema is already installed.
|
||||||
installed, err := checkSchema(db)
|
func install(ctx context.Context, db *sqlx.DB, fs stuffbin.FileSystem, idempotentInstall, prompt bool) error {
|
||||||
|
schemaInstalled, err := checkSchema(db)
|
||||||
if err != nil {
|
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"))
|
// Make sure the system user password is strong enough.
|
||||||
fmt.Print("Continue (y/n)? ")
|
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
|
var ok string
|
||||||
fmt.Scanf("%s", &ok)
|
fmt.Scanf("%s", &ok)
|
||||||
if !strings.EqualFold(ok, "y") {
|
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.
|
// Install schema.
|
||||||
if err := installSchema(db, fs); err != nil {
|
if err := installSchema(db, fs); err != nil {
|
||||||
log.Fatalf("error installing schema: %v", err)
|
log.Fatalf("error installing schema: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Println("Schema installed successfully")
|
log.Println("database schema installed successfully")
|
||||||
|
|
||||||
// Create system user.
|
// 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)
|
log.Fatalf("error creating system user: %v", err)
|
||||||
}
|
}
|
||||||
return nil
|
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.
|
// checkSchema verifies if the DB schema is already installed by querying a table.
|
||||||
func checkSchema(db *sqlx.DB) (bool, error) {
|
func checkSchema(db *sqlx.DB) (bool, error) {
|
||||||
if _, err := db.Exec(`SELECT * FROM settings LIMIT 1`); err != nil {
|
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, nil
|
||||||
}
|
}
|
||||||
return false, err
|
return false, err
|
||||||
|
|||||||
13
cmd/login.go
13
cmd/login.go
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
|
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
|
||||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||||
|
umodels "github.com/abhinavxd/libredesk/internal/user/models"
|
||||||
"github.com/valyala/fasthttp"
|
"github.com/valyala/fasthttp"
|
||||||
"github.com/zerodha/fastglue"
|
"github.com/zerodha/fastglue"
|
||||||
)
|
)
|
||||||
@@ -11,14 +12,20 @@ import (
|
|||||||
func handleLogin(r *fastglue.Request) error {
|
func handleLogin(r *fastglue.Request) error {
|
||||||
var (
|
var (
|
||||||
app = r.Context.(*App)
|
app = r.Context.(*App)
|
||||||
p = r.RequestCtx.PostArgs()
|
email = string(r.RequestCtx.PostArgs().Peek("email"))
|
||||||
email = string(p.Peek("email"))
|
password = r.RequestCtx.PostArgs().Peek("password")
|
||||||
password = p.Peek("password")
|
|
||||||
)
|
)
|
||||||
user, err := app.user.VerifyPassword(email, password)
|
user, err := app.user.VerifyPassword(email, password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return sendErrorEnvelope(r, err)
|
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{
|
if err := app.auth.SaveSession(amodels.User{
|
||||||
ID: user.ID,
|
ID: user.ID,
|
||||||
Email: user.Email.String,
|
Email: user.Email.String,
|
||||||
|
|||||||
34
cmd/main.go
34
cmd/main.go
@@ -6,8 +6,10 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/abhinavxd/libredesk/internal/ai"
|
"github.com/abhinavxd/libredesk/internal/ai"
|
||||||
auth_ "github.com/abhinavxd/libredesk/internal/auth"
|
auth_ "github.com/abhinavxd/libredesk/internal/auth"
|
||||||
@@ -34,7 +36,6 @@ import (
|
|||||||
"github.com/abhinavxd/libredesk/internal/team"
|
"github.com/abhinavxd/libredesk/internal/team"
|
||||||
"github.com/abhinavxd/libredesk/internal/template"
|
"github.com/abhinavxd/libredesk/internal/template"
|
||||||
"github.com/abhinavxd/libredesk/internal/user"
|
"github.com/abhinavxd/libredesk/internal/user"
|
||||||
"github.com/abhinavxd/libredesk/internal/ws"
|
|
||||||
"github.com/knadh/go-i18n"
|
"github.com/knadh/go-i18n"
|
||||||
"github.com/knadh/koanf/v2"
|
"github.com/knadh/koanf/v2"
|
||||||
"github.com/knadh/stuffbin"
|
"github.com/knadh/stuffbin"
|
||||||
@@ -50,7 +51,8 @@ var (
|
|||||||
frontendDir = "frontend/dist"
|
frontendDir = "frontend/dist"
|
||||||
|
|
||||||
// Injected at build time.
|
// Injected at build time.
|
||||||
buildString = ""
|
buildString string
|
||||||
|
versionString string
|
||||||
)
|
)
|
||||||
|
|
||||||
// App is the global app context which is passed and injected in the http handlers.
|
// 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
|
ai *ai.Manager
|
||||||
search *search.Manager
|
search *search.Manager
|
||||||
notifier *notifier.Service
|
notifier *notifier.Service
|
||||||
|
|
||||||
|
// Global state that stores data on an available app update.
|
||||||
|
update *AppUpdate
|
||||||
|
sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -99,9 +105,8 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build string injected at build time.
|
// 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.
|
// Load the config files into Koanf.
|
||||||
initConfig(ko)
|
initConfig(ko)
|
||||||
@@ -114,7 +119,7 @@ func main() {
|
|||||||
|
|
||||||
// Installer.
|
// Installer.
|
||||||
if ko.Bool("install") {
|
if ko.Bool("install") {
|
||||||
install(ctx, db, fs)
|
install(ctx, db, fs, ko.Bool("idempotent-install"), !ko.Bool("yes"))
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,10 +135,19 @@ func main() {
|
|||||||
log.Fatalf("error checking db schema: %v", err)
|
log.Fatalf("error checking db schema: %v", err)
|
||||||
}
|
}
|
||||||
if !installed {
|
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)
|
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.
|
// Load app settings from DB into the Koanf instance.
|
||||||
settings := initSettings(db)
|
settings := initSettings(db)
|
||||||
loadSettings(settings)
|
loadSettings(settings)
|
||||||
@@ -147,7 +161,6 @@ func main() {
|
|||||||
messageOutgoingScanInterval = ko.MustDuration("message.message_outoing_scan_interval")
|
messageOutgoingScanInterval = ko.MustDuration("message.message_outoing_scan_interval")
|
||||||
slaEvaluationInterval = ko.MustDuration("sla.evaluation_interval")
|
slaEvaluationInterval = ko.MustDuration("sla.evaluation_interval")
|
||||||
lo = initLogger(appName)
|
lo = initLogger(appName)
|
||||||
wsHub = ws.NewHub()
|
|
||||||
rdb = initRedis()
|
rdb = initRedis()
|
||||||
constants = initConstants()
|
constants = initConstants()
|
||||||
i18n = initI18n(fs)
|
i18n = initI18n(fs)
|
||||||
@@ -162,6 +175,7 @@ func main() {
|
|||||||
team = initTeam(db)
|
team = initTeam(db)
|
||||||
businessHours = initBusinessHours(db)
|
businessHours = initBusinessHours(db)
|
||||||
user = initUser(i18n, db)
|
user = initUser(i18n, db)
|
||||||
|
wsHub = initWS(user)
|
||||||
notifier = initNotifier(user)
|
notifier = initNotifier(user)
|
||||||
automation = initAutomationEngine(db)
|
automation = initAutomationEngine(db)
|
||||||
sla = initSLA(db, team, settings, businessHours)
|
sla = initSLA(db, team, settings, businessHours)
|
||||||
@@ -178,6 +192,7 @@ func main() {
|
|||||||
go notifier.Run(ctx)
|
go notifier.Run(ctx)
|
||||||
go sla.Run(ctx, slaEvaluationInterval)
|
go sla.Run(ctx, slaEvaluationInterval)
|
||||||
go media.DeleteUnlinkedMedia(ctx)
|
go media.DeleteUnlinkedMedia(ctx)
|
||||||
|
go user.MonitorAgentAvailability(ctx)
|
||||||
|
|
||||||
var app = &App{
|
var app = &App{
|
||||||
lo: lo,
|
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.
|
// Wait for shutdown signal.
|
||||||
<-ctx.Done()
|
<-ctx.Done()
|
||||||
colorlog.Red("Shutting down HTTP server...")
|
colorlog.Red("Shutting down HTTP server...")
|
||||||
|
|||||||
@@ -43,9 +43,7 @@ func tryAuth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
|
|||||||
// auth makes sure the user is logged in.
|
// auth makes sure the user is logged in.
|
||||||
func auth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
|
func auth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
|
||||||
return func(r *fastglue.Request) error {
|
return func(r *fastglue.Request) error {
|
||||||
var (
|
var app = r.Context.(*App)
|
||||||
app = r.Context.(*App)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Validate session and fetch user.
|
// Validate session and fetch user.
|
||||||
userSession, err := app.auth.ValidateSession(r)
|
userSession, err := app.auth.ValidateSession(r)
|
||||||
|
|||||||
36
cmd/oidc.go
36
cmd/oidc.go
@@ -44,6 +44,19 @@ func handleGetOIDC(r *fastglue.Request) error {
|
|||||||
return r.SendEnvelope(o)
|
return r.SendEnvelope(o)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleTestOIDC tests an OIDC provider URL by doing a discovery on the provider URL.
|
||||||
|
func handleTestOIDC(r *fastglue.Request) error {
|
||||||
|
var (
|
||||||
|
app = r.Context.(*App)
|
||||||
|
providerURL = string(r.RequestCtx.PostArgs().Peek("provider_url"))
|
||||||
|
)
|
||||||
|
if err := app.auth.TestProvider(providerURL); err != nil {
|
||||||
|
return sendErrorEnvelope(r, err)
|
||||||
|
}
|
||||||
|
return r.SendEnvelope("OIDC provider discovered successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCreateOIDC creates a new OIDC record.
|
||||||
func handleCreateOIDC(r *fastglue.Request) error {
|
func handleCreateOIDC(r *fastglue.Request) error {
|
||||||
var (
|
var (
|
||||||
app = r.Context.(*App)
|
app = r.Context.(*App)
|
||||||
@@ -52,18 +65,19 @@ func handleCreateOIDC(r *fastglue.Request) error {
|
|||||||
if err := r.Decode(&req, "json"); err != nil {
|
if err := r.Decode(&req, "json"); err != nil {
|
||||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Bad request", nil, envelope.GeneralError)
|
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)
|
return sendErrorEnvelope(r, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reload the auth manager to update the OIDC providers.
|
// Reload the auth manager to update the OIDC providers.
|
||||||
if err := reloadAuth(app); err != nil {
|
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")
|
return r.SendEnvelope("OIDC created successfully")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleUpdateOIDC updates an OIDC record.
|
||||||
func handleUpdateOIDC(r *fastglue.Request) error {
|
func handleUpdateOIDC(r *fastglue.Request) error {
|
||||||
var (
|
var (
|
||||||
app = r.Context.(*App)
|
app = r.Context.(*App)
|
||||||
@@ -79,8 +93,7 @@ func handleUpdateOIDC(r *fastglue.Request) error {
|
|||||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Bad request", nil, envelope.GeneralError)
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Bad request", nil, envelope.GeneralError)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = app.oidc.Update(id, req)
|
if err = app.oidc.Update(id, req); err != nil {
|
||||||
if err != nil {
|
|
||||||
return sendErrorEnvelope(r, err)
|
return sendErrorEnvelope(r, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,23 +104,16 @@ func handleUpdateOIDC(r *fastglue.Request) error {
|
|||||||
return r.SendEnvelope("OIDC updated successfully")
|
return r.SendEnvelope("OIDC updated successfully")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleDeleteOIDC deletes an OIDC record.
|
||||||
func handleDeleteOIDC(r *fastglue.Request) error {
|
func handleDeleteOIDC(r *fastglue.Request) error {
|
||||||
var (
|
var app = r.Context.(*App)
|
||||||
app = r.Context.(*App)
|
|
||||||
)
|
|
||||||
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
||||||
if err != nil || id == 0 {
|
if err != nil || id == 0 {
|
||||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
|
||||||
"Invalid oidc `id`.", nil, envelope.InputError)
|
"Invalid oidc `id`.", nil, envelope.InputError)
|
||||||
}
|
}
|
||||||
err = app.oidc.Delete(id)
|
if err = app.oidc.Delete(id); err != nil {
|
||||||
if err != nil {
|
|
||||||
return sendErrorEnvelope(r, err)
|
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")
|
return r.SendEnvelope("OIDC deleted successfully")
|
||||||
}
|
}
|
||||||
|
|||||||
20
cmd/roles.go
20
cmd/roles.go
@@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/zerodha/fastglue"
|
"github.com/zerodha/fastglue"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// handleGetRoles returns all roles
|
||||||
func handleGetRoles(r *fastglue.Request) error {
|
func handleGetRoles(r *fastglue.Request) error {
|
||||||
var (
|
var (
|
||||||
app = r.Context.(*App)
|
app = r.Context.(*App)
|
||||||
@@ -20,6 +21,7 @@ func handleGetRoles(r *fastglue.Request) error {
|
|||||||
return r.SendEnvelope(agents)
|
return r.SendEnvelope(agents)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleGetRole returns a single role
|
||||||
func handleGetRole(r *fastglue.Request) error {
|
func handleGetRole(r *fastglue.Request) error {
|
||||||
var (
|
var (
|
||||||
app = r.Context.(*App)
|
app = r.Context.(*App)
|
||||||
@@ -32,18 +34,19 @@ func handleGetRole(r *fastglue.Request) error {
|
|||||||
return r.SendEnvelope(role)
|
return r.SendEnvelope(role)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleDeleteRole deletes a role
|
||||||
func handleDeleteRole(r *fastglue.Request) error {
|
func handleDeleteRole(r *fastglue.Request) error {
|
||||||
var (
|
var (
|
||||||
app = r.Context.(*App)
|
app = r.Context.(*App)
|
||||||
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
||||||
)
|
)
|
||||||
err := app.role.Delete(id)
|
if err := app.role.Delete(id); err != nil {
|
||||||
if err != nil {
|
|
||||||
return sendErrorEnvelope(r, err)
|
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 {
|
func handleCreateRole(r *fastglue.Request) error {
|
||||||
var (
|
var (
|
||||||
app = r.Context.(*App)
|
app = r.Context.(*App)
|
||||||
@@ -52,13 +55,13 @@ func handleCreateRole(r *fastglue.Request) error {
|
|||||||
if err := r.Decode(&req, "json"); err != nil {
|
if err := r.Decode(&req, "json"); err != nil {
|
||||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
|
||||||
}
|
}
|
||||||
err := app.role.Create(req)
|
if err := app.role.Create(req); err != nil {
|
||||||
if err != nil {
|
|
||||||
return sendErrorEnvelope(r, err)
|
return sendErrorEnvelope(r, err)
|
||||||
}
|
}
|
||||||
return r.SendEnvelope(true)
|
return r.SendEnvelope("Role created successfully")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleUpdateRole updates a role
|
||||||
func handleUpdateRole(r *fastglue.Request) error {
|
func handleUpdateRole(r *fastglue.Request) error {
|
||||||
var (
|
var (
|
||||||
app = r.Context.(*App)
|
app = r.Context.(*App)
|
||||||
@@ -68,9 +71,8 @@ func handleUpdateRole(r *fastglue.Request) error {
|
|||||||
if err := r.Decode(&req, "json"); err != nil {
|
if err := r.Decode(&req, "json"); err != nil {
|
||||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
|
||||||
}
|
}
|
||||||
err := app.role.Update(id, req)
|
if err := app.role.Update(id, req);err != nil {
|
||||||
if err != nil {
|
|
||||||
return sendErrorEnvelope(r, err)
|
return sendErrorEnvelope(r, err)
|
||||||
}
|
}
|
||||||
return r.SendEnvelope(true)
|
return r.SendEnvelope("Role updated successfully")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,15 @@ func handleGetGeneralSettings(r *fastglue.Request) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return sendErrorEnvelope(r, err)
|
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.
|
// handleUpdateGeneralSettings updates general settings.
|
||||||
|
|||||||
98
cmd/updates.go
Normal file
98
cmd/updates.go
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
// Copyright Kailash Nadh (https://github.com/knadh/listmonk)
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0
|
||||||
|
// Adapted from listmonk for Libredesk.
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/mod/semver"
|
||||||
|
)
|
||||||
|
|
||||||
|
const updateCheckURL = "https://updates.libredesk.io/updates.json"
|
||||||
|
|
||||||
|
type AppUpdate struct {
|
||||||
|
Update struct {
|
||||||
|
ReleaseVersion string `json:"release_version"`
|
||||||
|
ReleaseDate string `json:"release_date"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
|
||||||
|
// This is computed and set locally based on the local version.
|
||||||
|
IsNew bool `json:"is_new"`
|
||||||
|
} `json:"update"`
|
||||||
|
Messages []struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Priority string `json:"priority"`
|
||||||
|
} `json:"messages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var reSemver = regexp.MustCompile(`-(.*)`)
|
||||||
|
|
||||||
|
// checkUpdates is a blocking function that checks for updates to the app
|
||||||
|
// at the given intervals. On detecting a new update (new semver), it
|
||||||
|
// sets the global update status that renders a prompt on the UI.
|
||||||
|
func checkUpdates(curVersion string, interval time.Duration, app *App) {
|
||||||
|
// Strip -* suffix.
|
||||||
|
curVersion = reSemver.ReplaceAllString(curVersion, "")
|
||||||
|
|
||||||
|
fnCheck := func() {
|
||||||
|
resp, err := http.Get(updateCheckURL)
|
||||||
|
if err != nil {
|
||||||
|
app.lo.Error("error checking for app updates", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
app.lo.Error("non-ok status code checking for app updates", "status", resp.StatusCode)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
app.lo.Error("error reading response body", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
var out AppUpdate
|
||||||
|
if err := json.Unmarshal(b, &out); err != nil {
|
||||||
|
app.lo.Error("error unmarshalling response body", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// There is an update. Set it on the global app state.
|
||||||
|
if semver.IsValid(out.Update.ReleaseVersion) {
|
||||||
|
v := reSemver.ReplaceAllString(out.Update.ReleaseVersion, "")
|
||||||
|
if semver.Compare(v, curVersion) > 0 {
|
||||||
|
out.Update.IsNew = true
|
||||||
|
app.lo.Info("new update available", "version", out.Update.ReleaseVersion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.Lock()
|
||||||
|
app.update = &out
|
||||||
|
app.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Give a 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
148
cmd/upgrade.go
Normal 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)
|
||||||
|
}
|
||||||
21
cmd/users.go
21
cmd/users.go
@@ -22,7 +22,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
maxAvatarSizeMB = 5
|
maxAvatarSizeMB = 20
|
||||||
)
|
)
|
||||||
|
|
||||||
// handleGetUsers returns all users.
|
// handleGetUsers returns all users.
|
||||||
@@ -39,9 +39,7 @@ func handleGetUsers(r *fastglue.Request) error {
|
|||||||
|
|
||||||
// handleGetUsersCompact returns all users in a compact format.
|
// handleGetUsersCompact returns all users in a compact format.
|
||||||
func handleGetUsersCompact(r *fastglue.Request) error {
|
func handleGetUsersCompact(r *fastglue.Request) error {
|
||||||
var (
|
var app = r.Context.(*App)
|
||||||
app = r.Context.(*App)
|
|
||||||
)
|
|
||||||
agents, err := app.user.GetAllCompact()
|
agents, err := app.user.GetAllCompact()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, "")
|
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, "")
|
||||||
@@ -66,6 +64,19 @@ func handleGetUser(r *fastglue.Request) error {
|
|||||||
return r.SendEnvelope(user)
|
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.
|
// handleGetCurrentUserTeams returns the teams of a user.
|
||||||
func handleGetCurrentUserTeams(r *fastglue.Request) error {
|
func handleGetCurrentUserTeams(r *fastglue.Request) error {
|
||||||
var (
|
var (
|
||||||
@@ -228,7 +239,7 @@ func handleCreateUser(r *fastglue.Request) error {
|
|||||||
Provider: notifier.ProviderEmail,
|
Provider: notifier.ProviderEmail,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
app.lo.Error("error sending notification message", "error", err)
|
app.lo.Error("error sending notification message", "error", err)
|
||||||
return r.SendEnvelope("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.")
|
return r.SendEnvelope("User created successfully.")
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
[app]
|
[app]
|
||||||
log_level = "debug"
|
log_level = "debug"
|
||||||
env = "dev"
|
env = "dev"
|
||||||
|
check_updates = true
|
||||||
|
|
||||||
# HTTP server.
|
# HTTP server.
|
||||||
[app.server]
|
[app.server]
|
||||||
@@ -9,16 +10,16 @@ address = "0.0.0.0:9000"
|
|||||||
socket = ""
|
socket = ""
|
||||||
read_timeout = "5s"
|
read_timeout = "5s"
|
||||||
write_timeout = "5s"
|
write_timeout = "5s"
|
||||||
max_body_size = 10000000
|
max_body_size = 500000000
|
||||||
keepalive_timeout = "10s"
|
keepalive_timeout = "10s"
|
||||||
|
|
||||||
# File upload provider.
|
# File upload provider to use, either `fs` or `s3`.
|
||||||
[upload]
|
[upload]
|
||||||
provider = "fs"
|
provider = "fs"
|
||||||
|
|
||||||
# Filesytem provider.
|
# Filesytem provider.
|
||||||
[upload.fs]
|
[upload.fs]
|
||||||
upload_path = '/home/ubuntu/uploads'
|
upload_path = 'uploads'
|
||||||
|
|
||||||
# S3 provider.
|
# S3 provider.
|
||||||
[upload.s3]
|
[upload.s3]
|
||||||
@@ -32,6 +33,7 @@ expiry = "6h"
|
|||||||
|
|
||||||
# Postgres.
|
# Postgres.
|
||||||
[db]
|
[db]
|
||||||
|
# If using docker compose, use the service name as the host. e.g. db
|
||||||
host = "127.0.0.1"
|
host = "127.0.0.1"
|
||||||
port = 5432
|
port = 5432
|
||||||
user = "postgres"
|
user = "postgres"
|
||||||
@@ -44,6 +46,7 @@ max_lifetime = "300s"
|
|||||||
|
|
||||||
# Redis.
|
# Redis.
|
||||||
[redis]
|
[redis]
|
||||||
|
# If using docker compose, use the service name as the host. e.g. redis:6379
|
||||||
address = "127.0.0.1:6379"
|
address = "127.0.0.1:6379"
|
||||||
password = ""
|
password = ""
|
||||||
db = 0
|
db = 0
|
||||||
|
|||||||
62
docker-compose.yml
Normal file
62
docker-compose.yml
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
services:
|
||||||
|
# Libredesk app
|
||||||
|
app:
|
||||||
|
image: libredesk/libredesk:latest
|
||||||
|
container_name: libredesk_app
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "9000:9000"
|
||||||
|
environment:
|
||||||
|
# If the password is set during first docker-compose up, the system user password will be set to this value.
|
||||||
|
# You can always set system user password later by running `docker exec -it libredesk_app ./libredesk --set-system-user-password`.
|
||||||
|
LIBREDESK_SYSTEM_USER_PASSWORD: ${LIBREDESK_SYSTEM_USER_PASSWORD:-}
|
||||||
|
networks:
|
||||||
|
- libredesk
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
- redis
|
||||||
|
volumes:
|
||||||
|
- ./uploads:/libredesk/uploads:rw
|
||||||
|
- ./config.toml:/libredesk/config.toml
|
||||||
|
command: [sh, -c, "./libredesk --install --idempotent-install --yes --config /libredesk/config.toml && ./libredesk --upgrade --yes --config /libredesk/config.toml && ./libredesk --config /libredesk/config.toml"]
|
||||||
|
|
||||||
|
# PostgreSQL database
|
||||||
|
db:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
container_name: libredesk_db
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- libredesk
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
environment:
|
||||||
|
# Set these environment variables to configure the database, defaults to libredesk.
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-libredesk}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-libredesk}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-libredesk}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U libredesk"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 6
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: libredesk_redis
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
networks:
|
||||||
|
- libredesk
|
||||||
|
volumes:
|
||||||
|
- redis-data:/data
|
||||||
|
|
||||||
|
networks:
|
||||||
|
libredesk:
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres-data:
|
||||||
|
redis-data:
|
||||||
31
docs/docs/developer-setup.md
Normal file
31
docs/docs/developer-setup.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Developer Setup
|
||||||
|
|
||||||
|
Libredesk is a monorepo with a Go backend and a Vue.js frontend. The frontend uses Shadcn for UI components.
|
||||||
|
|
||||||
|
### Pre-requisites
|
||||||
|
|
||||||
|
- `go`
|
||||||
|
- `nodejs` (if you are working on the frontend) and `pnpm`
|
||||||
|
- Postgres database (>= 13)
|
||||||
|
|
||||||
|
### First time setup
|
||||||
|
|
||||||
|
Clone the repository:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone https://github.com/abhinavxd/libredesk.git
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Copy `config.toml.sample` as `config.toml` and add your config.
|
||||||
|
2. Run `make` to build the libredesk binary. Once the binary is built, run `./libredesk --install` to run the DB setup and set the System user password.
|
||||||
|
|
||||||
|
### Running the Dev Environment
|
||||||
|
|
||||||
|
1. Run `make run-backend` to start the libredesk backend dev server on `:9000`.
|
||||||
|
2. Run `make run-frontend` to start the Vue frontend in dev mode using pnpm on `:8000`. Requests are proxied to the backend running on `:9000` check `vite.config.js` for the proxy config.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Production Build
|
||||||
|
|
||||||
|
Run `make` to build the Go binary, build the Javascript frontend, and embed the static assets producing a single self-contained binary, `libredesk`.
|
||||||
13
docs/docs/index.md
Normal file
13
docs/docs/index.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Introduction
|
||||||
|
|
||||||
|
Libredesk is an open source, self-hosted customer support desk. Single binary app.
|
||||||
|
|
||||||
|
|
||||||
|
<div style="border: 1px solid #ccc; padding: 1px; border-radius:5px; box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.1); background-color: #fff;">
|
||||||
|
<a href="https://libredesk.io">
|
||||||
|
<img src="https://hebbkx1anhila5yf.public.blob.vercel-storage.com/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
48
docs/docs/installation.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# Installation
|
||||||
|
|
||||||
|
Libredesk is a single binary application that requires postgres and redis to run. You can install it using the binary or docker.
|
||||||
|
|
||||||
|
## Binary
|
||||||
|
|
||||||
|
1. Download the [latest release](https://github.com/abhinavxd/libredesk/releases) and extract the libredesk binary.
|
||||||
|
2. `./libredesk --install` to install the tables in the Postgres DB (⩾ 13) and set the System user password.
|
||||||
|
3. Run `./libredesk` and visit `http://localhost:9000` and login with the email `System` and the password you set during installation.
|
||||||
|
|
||||||
|
!!! Tip
|
||||||
|
To set the System user password during installation, set the environment variables:
|
||||||
|
`LIBREDESK_SYSTEM_USER_PASSWORD=xxxxxxxxxxx ./libredesk --install`
|
||||||
|
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
The latest image is available on DockerHub at `libredesk/libredesk:latest`
|
||||||
|
|
||||||
|
The recommended method is to download the [docker-compose.yml](https://github.com/abhinavxd/libredesk/blob/main/docker-compose.yml) file, customize it for your environment and then to simply run `docker compose up -d`.
|
||||||
|
|
||||||
|
```shell
|
||||||
|
# Download the compose file and the sample config file in the current directory.
|
||||||
|
curl -LO https://github.com/abhinavxd/libredesk/raw/main/docker-compose.yml
|
||||||
|
curl -LO https://github.com/abhinavxd/libredesk/raw/main/config.sample.toml
|
||||||
|
|
||||||
|
# Copy the config.sample.toml to config.toml and edit it as needed.
|
||||||
|
cp config.sample.toml config.toml
|
||||||
|
|
||||||
|
# Run the services in the background.
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# Setting System user password.
|
||||||
|
docker exec -it libredesk_app ./libredesk --set-system-user-password
|
||||||
|
```
|
||||||
|
|
||||||
|
Go to `http://localhost:9000` and login with the email `System` and the password you set using the `--set-system-user-password` command.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Compiling from source
|
||||||
|
|
||||||
|
To compile the latest unreleased version (`main` branch):
|
||||||
|
|
||||||
|
1. Make sure `go`, `nodejs`, and `pnpm` are installed on your system.
|
||||||
|
2. `git clone git@github.com:abhinavxd/libredesk.git`
|
||||||
|
3. `cd libredesk && make`. This will generate the `libredesk` binary.
|
||||||
18
docs/docs/upgrade.md
Normal file
18
docs/docs/upgrade.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Upgrade
|
||||||
|
|
||||||
|
!!! Warning
|
||||||
|
Always take a backup of the Postgres database before upgrading Libredesk.
|
||||||
|
|
||||||
|
## Binary
|
||||||
|
- Stop running libredesk binary.
|
||||||
|
- Download the [latest release](https://github.com/abhinavxd/libredesk/releases) and extract the libredesk binary and overwrite the previous version.
|
||||||
|
- `./libredesk --upgrade` to upgrade an existing database schema. Upgrades are idempotent and running them multiple times have no side effects.
|
||||||
|
- Run `./libredesk` again.
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
```shell
|
||||||
|
docker compose down app
|
||||||
|
docker compose pull
|
||||||
|
docker compose up app -d
|
||||||
|
```
|
||||||
34
docs/mkdocs.yml
Normal file
34
docs/mkdocs.yml
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
site_name: Libredesk Documentation
|
||||||
|
theme:
|
||||||
|
name: material
|
||||||
|
language: en
|
||||||
|
font:
|
||||||
|
text: Source Sans Pro
|
||||||
|
code: Roboto Mono
|
||||||
|
weights:
|
||||||
|
- 400
|
||||||
|
- 700
|
||||||
|
direction: ltr
|
||||||
|
palette:
|
||||||
|
primary: white
|
||||||
|
accent: red
|
||||||
|
features:
|
||||||
|
- navigation.indexes
|
||||||
|
- navigation.sections
|
||||||
|
- content.code.copy
|
||||||
|
extra:
|
||||||
|
search:
|
||||||
|
language: en
|
||||||
|
|
||||||
|
markdown_extensions:
|
||||||
|
- admonition
|
||||||
|
- codehilite
|
||||||
|
- toc:
|
||||||
|
permalink: true
|
||||||
|
|
||||||
|
nav:
|
||||||
|
- Introduction: index.md
|
||||||
|
- Getting Started:
|
||||||
|
- Installation: installation.md
|
||||||
|
- Upgrade: upgrade.md
|
||||||
|
- Developer Setup: developer-setup.md
|
||||||
8
frontend/.vscode/extensions.json
vendored
8
frontend/.vscode/extensions.json
vendored
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"recommendations": [
|
|
||||||
"Vue.volar",
|
|
||||||
"Vue.vscode-typescript-vue-plugin",
|
|
||||||
"dbaeumer.vscode-eslint",
|
|
||||||
"esbenp.prettier-vscode"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link
|
<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">
|
rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "libredesk",
|
"name": "libredesk",
|
||||||
"version": "0.0.0",
|
"version": "0.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -18,41 +18,29 @@
|
|||||||
"@formkit/auto-animate": "^0.8.2",
|
"@formkit/auto-animate": "^0.8.2",
|
||||||
"@internationalized/date": "^3.5.5",
|
"@internationalized/date": "^3.5.5",
|
||||||
"@radix-icons/vue": "^1.0.0",
|
"@radix-icons/vue": "^1.0.0",
|
||||||
"@tailwindcss/typography": "^0.5.10",
|
|
||||||
"@tanstack/vue-table": "^8.19.2",
|
"@tanstack/vue-table": "^8.19.2",
|
||||||
"@tiptap/extension-image": "^2.5.9",
|
"@tiptap/extension-image": "^2.5.9",
|
||||||
"@tiptap/extension-link": "^2.9.1",
|
"@tiptap/extension-link": "^2.9.1",
|
||||||
"@tiptap/extension-ordered-list": "^2.4.0",
|
|
||||||
"@tiptap/extension-placeholder": "^2.4.0",
|
"@tiptap/extension-placeholder": "^2.4.0",
|
||||||
"@tiptap/pm": "^2.4.0",
|
"@tiptap/pm": "^2.4.0",
|
||||||
"@tiptap/starter-kit": "^2.4.0",
|
"@tiptap/starter-kit": "^2.4.0",
|
||||||
"@tiptap/suggestion": "^2.4.0",
|
|
||||||
"@tiptap/vue-3": "^2.4.0",
|
"@tiptap/vue-3": "^2.4.0",
|
||||||
"@unovis/ts": "^1.4.4",
|
"@unovis/ts": "^1.4.4",
|
||||||
"@unovis/vue": "^1.4.4",
|
"@unovis/vue": "^1.4.4",
|
||||||
"@vee-validate/zod": "^4.13.2",
|
"@vee-validate/zod": "^4.13.2",
|
||||||
"@vue/reactivity": "^3.4.15",
|
|
||||||
"@vue/runtime-core": "^3.4.15",
|
|
||||||
"@vueup/vue-quill": "^1.2.0",
|
"@vueup/vue-quill": "^1.2.0",
|
||||||
"@vueuse/core": "^12.4.0",
|
"@vueuse/core": "^12.4.0",
|
||||||
"add": "^2.0.6",
|
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"codeflask": "^1.4.1",
|
"codeflask": "^1.4.1",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"install": "^0.13.0",
|
|
||||||
"lucide-vue-next": "^0.378.0",
|
"lucide-vue-next": "^0.378.0",
|
||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"npm": "^10.4.0",
|
|
||||||
"npx": "^10.2.2",
|
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"qs": "^6.12.1",
|
"qs": "^6.12.1",
|
||||||
"radix-vue": "latest",
|
"radix-vue": "latest",
|
||||||
"shadcn-vue": "latest",
|
|
||||||
"tailwind-merge": "^2.3.0",
|
"tailwind-merge": "^2.3.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
|
||||||
"textarea": "^0.3.0",
|
|
||||||
"vee-validate": "^4.13.2",
|
"vee-validate": "^4.13.2",
|
||||||
"vue": "^3.4.37",
|
"vue": "^3.4.37",
|
||||||
"vue-i18n": "9",
|
"vue-i18n": "9",
|
||||||
@@ -68,7 +56,7 @@
|
|||||||
"@rushstack/eslint-patch": "^1.3.3",
|
"@rushstack/eslint-patch": "^1.3.3",
|
||||||
"@vitejs/plugin-vue": "^5.0.3",
|
"@vitejs/plugin-vue": "^5.0.3",
|
||||||
"@vue/eslint-config-prettier": "^8.0.0",
|
"@vue/eslint-config-prettier": "^8.0.0",
|
||||||
"autoprefixer": "latest",
|
"autoprefixer": "^10.4.20",
|
||||||
"cypress": "^13.6.3",
|
"cypress": "^13.6.3",
|
||||||
"eslint": "^8.49.0",
|
"eslint": "^8.49.0",
|
||||||
"eslint-plugin-cypress": "^2.15.1",
|
"eslint-plugin-cypress": "^2.15.1",
|
||||||
@@ -78,6 +66,7 @@
|
|||||||
"sass": "^1.70.0",
|
"sass": "^1.70.0",
|
||||||
"start-server-and-test": "^2.0.3",
|
"start-server-and-test": "^2.0.3",
|
||||||
"tailwindcss": "latest",
|
"tailwindcss": "latest",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"vite": "^5.4.9"
|
"vite": "^5.4.9"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@9.15.3+sha512.1f79bc245a66eb0b07c5d4d83131240774642caaa86ef7d0434ab47c0d16f66b04e21e0c086eb61e62c77efc4d7f7ec071afad3796af64892fae66509173893a"
|
"packageManager": "pnpm@9.15.3+sha512.1f79bc245a66eb0b07c5d4d83131240774642caaa86ef7d0434ab47c0d16f66b04e21e0c086eb61e62c77efc4d7f7ec071afad3796af64892fae66509173893a"
|
||||||
|
|||||||
1640
frontend/pnpm-lock.yaml
generated
1640
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -47,8 +47,16 @@
|
|||||||
@edit-view="editView"
|
@edit-view="editView"
|
||||||
@delete-view="deleteView"
|
@delete-view="deleteView"
|
||||||
>
|
>
|
||||||
|
<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 />
|
<PageHeader />
|
||||||
<RouterView />
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<RouterView class="flex-grow" />
|
||||||
|
</div>
|
||||||
<ViewForm v-model:openDialog="openCreateViewForm" v-model:view="view" />
|
<ViewForm v-model:openDialog="openCreateViewForm" v-model:view="view" />
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,8 +81,10 @@ import { useTeamStore } from '@/stores/team'
|
|||||||
import { useSlaStore } from '@/stores/sla'
|
import { useSlaStore } from '@/stores/sla'
|
||||||
import { useMacroStore } from '@/stores/macro'
|
import { useMacroStore } from '@/stores/macro'
|
||||||
import { useTagStore } from '@/stores/tag'
|
import { useTagStore } from '@/stores/tag'
|
||||||
|
import { useIdleDetection } from '@/composables/useIdleDetection'
|
||||||
import PageHeader from './components/layout/PageHeader.vue'
|
import PageHeader from './components/layout/PageHeader.vue'
|
||||||
import ViewForm from '@/features/view/ViewForm.vue'
|
import ViewForm from '@/features/view/ViewForm.vue'
|
||||||
|
import AppUpdate from '@/components/update/AppUpdate.vue'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import { toast as sooner } from 'vue-sonner'
|
import { toast as sooner } from 'vue-sonner'
|
||||||
import Sidebar from '@/components/sidebar/Sidebar.vue'
|
import Sidebar from '@/components/sidebar/Sidebar.vue'
|
||||||
@@ -109,6 +119,8 @@ const view = ref({})
|
|||||||
const openCreateViewForm = ref(false)
|
const openCreateViewForm = ref(false)
|
||||||
|
|
||||||
initWS()
|
initWS()
|
||||||
|
useIdleDetection()
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
initToaster()
|
initToaster()
|
||||||
listenViewRefresh()
|
listenViewRefresh()
|
||||||
@@ -117,8 +129,10 @@ onMounted(() => {
|
|||||||
|
|
||||||
// initialize data stores
|
// initialize data stores
|
||||||
const initStores = async () => {
|
const initStores = async () => {
|
||||||
|
if (!userStore.userID) {
|
||||||
|
await userStore.getCurrentUser()
|
||||||
|
}
|
||||||
await Promise.allSettled([
|
await Promise.allSettled([
|
||||||
userStore.getCurrentUser(),
|
|
||||||
getUserViews(),
|
getUserViews(),
|
||||||
conversationStore.fetchStatuses(),
|
conversationStore.fetchStatuses(),
|
||||||
conversationStore.fetchPriorities(),
|
conversationStore.fetchPriorities(),
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ const createOIDC = (data) =>
|
|||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
const testOIDC = (data) => http.post('/api/v1/oidc/test', data)
|
||||||
const getAllEnabledOIDC = () => http.get('/api/v1/oidc/enabled')
|
const getAllEnabledOIDC = () => http.get('/api/v1/oidc/enabled')
|
||||||
const getAllOIDC = () => http.get('/api/v1/oidc')
|
const getAllOIDC = () => http.get('/api/v1/oidc')
|
||||||
const getOIDC = (id) => http.get(`/api/v1/oidc/${id}`)
|
const getOIDC = (id) => http.get(`/api/v1/oidc/${id}`)
|
||||||
@@ -168,6 +169,7 @@ const updateCurrentUser = (data) =>
|
|||||||
const deleteUserAvatar = () => http.delete('/api/v1/users/me/avatar')
|
const deleteUserAvatar = () => http.delete('/api/v1/users/me/avatar')
|
||||||
const getCurrentUser = () => http.get('/api/v1/users/me')
|
const getCurrentUser = () => http.get('/api/v1/users/me')
|
||||||
const getCurrentUserTeams = () => http.get('/api/v1/users/me/teams')
|
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 getTags = () => http.get('/api/v1/tags')
|
||||||
const upsertTags = (uuid, data) => http.post(`/api/v1/conversations/${uuid}/tags`, data)
|
const upsertTags = (uuid, data) => http.post(`/api/v1/conversations/${uuid}/tags`, data)
|
||||||
const updateAssignee = (uuid, assignee_type, data) => http.put(`/api/v1/conversations/${uuid}/assignee/${assignee_type}`, data)
|
const updateAssignee = (uuid, assignee_type, data) => http.put(`/api/v1/conversations/${uuid}/assignee/${assignee_type}`, data)
|
||||||
@@ -322,6 +324,7 @@ export default {
|
|||||||
uploadMedia,
|
uploadMedia,
|
||||||
updateAssigneeLastSeen,
|
updateAssigneeLastSeen,
|
||||||
updateUser,
|
updateUser,
|
||||||
|
updateCurrentUserAvailability,
|
||||||
updateAutomationRule,
|
updateAutomationRule,
|
||||||
updateAutomationRuleWeights,
|
updateAutomationRuleWeights,
|
||||||
updateAutomationRulesExecutionMode,
|
updateAutomationRulesExecutionMode,
|
||||||
@@ -344,6 +347,7 @@ export default {
|
|||||||
getAllEnabledOIDC,
|
getAllEnabledOIDC,
|
||||||
getOIDC,
|
getOIDC,
|
||||||
updateOIDC,
|
updateOIDC,
|
||||||
|
testOIDC,
|
||||||
deleteOIDC,
|
deleteOIDC,
|
||||||
getTemplate,
|
getTemplate,
|
||||||
getTemplates,
|
getTemplates,
|
||||||
|
|||||||
@@ -6,13 +6,6 @@
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-content {
|
|
||||||
height: 100vh;
|
|
||||||
overflow-y: scroll;
|
|
||||||
padding-bottom: 100px;
|
|
||||||
background-color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
@@ -54,44 +47,43 @@
|
|||||||
--destructive: 0 84.2% 60.2%;
|
--destructive: 0 84.2% 60.2%;
|
||||||
--destructive-foreground: 0 0% 98%;
|
--destructive-foreground: 0 0% 98%;
|
||||||
|
|
||||||
--border:240 5.9% 90%;
|
--border: 240 5.9% 90%;
|
||||||
--input:240 5.9% 90%;
|
--input: 240 5.9% 90%;
|
||||||
--ring:240 5.9% 10%;
|
--ring: 240 5.9% 10%;
|
||||||
--radius: 0.75rem;
|
--radius: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background:240 10% 3.9%;
|
--background: 240 10% 3.9%;
|
||||||
--foreground:0 0% 98%;
|
--foreground: 0 0% 98%;
|
||||||
|
|
||||||
--card:240 10% 3.9%;
|
--card: 240 10% 3.9%;
|
||||||
--card-foreground:0 0% 98%;
|
--card-foreground: 0 0% 98%;
|
||||||
|
|
||||||
--popover:240 10% 3.9%;
|
--popover: 240 10% 3.9%;
|
||||||
--popover-foreground:0 0% 98%;
|
--popover-foreground: 0 0% 98%;
|
||||||
|
|
||||||
--primary:0 0% 98%;
|
--primary: 0 0% 98%;
|
||||||
--primary-foreground:240 5.9% 10%;
|
--primary-foreground: 240 5.9% 10%;
|
||||||
|
|
||||||
--secondary:240 3.7% 15.9%;
|
--secondary: 240 3.7% 15.9%;
|
||||||
--secondary-foreground:0 0% 98%;
|
--secondary-foreground: 0 0% 98%;
|
||||||
|
|
||||||
--muted:240 3.7% 15.9%;
|
--muted: 240 3.7% 15.9%;
|
||||||
--muted-foreground:240 5% 64.9%;
|
--muted-foreground: 240 5% 64.9%;
|
||||||
|
|
||||||
--accent:240 3.7% 15.9%;
|
--accent: 240 3.7% 15.9%;
|
||||||
--accent-foreground:0 0% 98%;
|
--accent-foreground: 0 0% 98%;
|
||||||
|
|
||||||
--destructive:0 62.8% 30.6%;
|
--destructive: 0 62.8% 30.6%;
|
||||||
--destructive-foreground:0 0% 98%;
|
--destructive-foreground: 0 0% 98%;
|
||||||
|
|
||||||
--border:240 3.7% 15.9%;
|
--border: 240 3.7% 15.9%;
|
||||||
--input:240 3.7% 15.9%;
|
--input: 240 3.7% 15.9%;
|
||||||
--ring:240 4.9% 83.9%;
|
--ring: 240 4.9% 83.9%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--vis-tooltip-background-color: none !important;
|
--vis-tooltip-background-color: none !important;
|
||||||
@@ -239,7 +231,7 @@
|
|||||||
// Sidebar start
|
// Sidebar start
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--sidebar-background: 0 0% 97%;
|
--sidebar-background: 0 0% 96%;
|
||||||
--sidebar-foreground: 240 5.3% 26.1%;
|
--sidebar-foreground: 240 5.3% 26.1%;
|
||||||
--sidebar-primary: 240 5.9% 10%;
|
--sidebar-primary: 240 5.9% 10%;
|
||||||
--sidebar-primary-foreground: 0 0% 98%;
|
--sidebar-primary-foreground: 0 0% 98%;
|
||||||
@@ -320,3 +312,7 @@ a[data-active='false']:hover {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-radix-popper-content-wrapper] {
|
||||||
|
z-index: 9999 !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="!isHidden">
|
<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" />
|
<SidebarTrigger class="cursor-pointer w-4 h-4" />
|
||||||
<span class="text-2xl font-semibold">
|
<span class="text-xl font-semibold text-gray-800">
|
||||||
{{ title }}
|
{{ title }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,23 @@
|
|||||||
<template>
|
<template>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger as-child>
|
<DropdownMenuTrigger as-child>
|
||||||
<SidebarMenuButton size="lg"
|
<SidebarMenuButton
|
||||||
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground p-0">
|
size="lg"
|
||||||
<Avatar class="h-8 w-8 rounded-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" />
|
<AvatarImage :src="userStore.avatar" alt="Abhinav" />
|
||||||
<AvatarFallback class="rounded-lg">
|
<AvatarFallback class="rounded-lg">
|
||||||
{{ userStore.getInitials }}
|
{{ userStore.getInitials }}
|
||||||
</AvatarFallback>
|
</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>
|
</Avatar>
|
||||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||||
<span class="truncate font-semibold">{{ userStore.getFullName }}</span>
|
<span class="truncate font-semibold">{{ userStore.getFullName }}</span>
|
||||||
@@ -16,9 +26,12 @@
|
|||||||
<ChevronsUpDown class="ml-auto size-4" />
|
<ChevronsUpDown class="ml-auto size-4" />
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent class="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg" side="bottom"
|
<DropdownMenuContent
|
||||||
:side-offset="4">
|
class="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
|
||||||
<DropdownMenuLabel class="p-0 font-normal">
|
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">
|
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||||
<Avatar class="h-8 w-8 rounded-lg">
|
<Avatar class="h-8 w-8 rounded-lg">
|
||||||
<AvatarImage :src="userStore.avatar" alt="Abhinav" />
|
<AvatarImage :src="userStore.avatar" alt="Abhinav" />
|
||||||
@@ -31,6 +44,13 @@
|
|||||||
<span class="truncate text-xs">{{ userStore.email }}</span>
|
<span class="truncate text-xs">{{ userStore.email }}</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
@@ -58,21 +78,12 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import {
|
import { SidebarMenuButton } from '@/components/ui/sidebar'
|
||||||
SidebarMenuButton,
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||||
} from '@/components/ui/sidebar'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import {
|
import { ChevronsUpDown, CircleUserRound, LogOut } from 'lucide-vue-next'
|
||||||
Avatar,
|
|
||||||
AvatarFallback,
|
|
||||||
AvatarImage,
|
|
||||||
} from '@/components/ui/avatar'
|
|
||||||
import {
|
|
||||||
ChevronsUpDown,
|
|
||||||
CircleUserRound,
|
|
||||||
LogOut,
|
|
||||||
} from 'lucide-vue-next'
|
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
<Breadcrumb>
|
<Breadcrumb>
|
||||||
<BreadcrumbList>
|
<BreadcrumbList>
|
||||||
<BreadcrumbItem v-for="(item, index) in links" :key="index">
|
<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 }}
|
{{ item.label }}
|
||||||
</router-link>
|
</router-link>
|
||||||
|
<span v-else>{{ item.label }}</span>
|
||||||
<BreadcrumbSeparator v-if="index < links.length - 1">
|
<BreadcrumbSeparator v-if="index < links.length - 1">
|
||||||
<ChevronRight />
|
<ChevronRight />
|
||||||
</BreadcrumbSeparator>
|
</BreadcrumbSeparator>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Primitive } from 'radix-vue'
|
|||||||
import { buttonVariants } from '.'
|
import { buttonVariants } from '.'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
|
import { DotLoader } from '@/components/ui/loader'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
variant: { type: null, required: false },
|
variant: { type: null, required: false },
|
||||||
@@ -29,11 +30,7 @@ const computedClass = computed(() => {
|
|||||||
:class="computedClass"
|
:class="computedClass"
|
||||||
:disabled="isLoading || isDisabled"
|
:disabled="isLoading || isDisabled"
|
||||||
>
|
>
|
||||||
<span v-if="isLoading" class="dot-loader">
|
<DotLoader v-if="isLoading" />
|
||||||
<span class="dot"></span>
|
|
||||||
<span class="dot"></span>
|
|
||||||
<span class="dot"></span>
|
|
||||||
</span>
|
|
||||||
<slot v-else />
|
<slot v-else />
|
||||||
</Primitive>
|
</Primitive>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { VisDonut, VisSingleContainer } from '@unovis/vue'
|
|
||||||
import { Donut } from '@unovis/ts'
|
|
||||||
import { computed, ref } from 'vue'
|
|
||||||
import { useMounted } from '@vueuse/core'
|
|
||||||
import { ChartSingleTooltip, defaultColors } from '@/components/ui/chart'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
data: { type: Array, required: true },
|
|
||||||
colors: { type: Array, required: false },
|
|
||||||
index: { type: null, required: true },
|
|
||||||
margin: {
|
|
||||||
type: null,
|
|
||||||
required: false,
|
|
||||||
default: () => ({ top: 0, bottom: 0, left: 0, right: 0 })
|
|
||||||
},
|
|
||||||
showLegend: { type: Boolean, required: false, default: true },
|
|
||||||
showTooltip: { type: Boolean, required: false, default: true },
|
|
||||||
filterOpacity: { type: Number, required: false, default: 0.2 },
|
|
||||||
category: { type: String, required: true },
|
|
||||||
type: { type: String, required: false, default: 'donut' },
|
|
||||||
sortFunction: { type: Function, required: false, default: () => undefined },
|
|
||||||
valueFormatter: { type: Function, required: false, default: (tick) => `${tick}` },
|
|
||||||
customTooltip: { type: null, required: false }
|
|
||||||
})
|
|
||||||
|
|
||||||
const category = computed(() => props.category)
|
|
||||||
const index = computed(() => props.index)
|
|
||||||
|
|
||||||
const isMounted = useMounted()
|
|
||||||
const activeSegmentKey = ref()
|
|
||||||
const colors = computed(() =>
|
|
||||||
props.colors?.length
|
|
||||||
? props.colors
|
|
||||||
: defaultColors(props.data.filter((d) => d[props.category]).filter(Boolean).length)
|
|
||||||
)
|
|
||||||
const legendItems = computed(() =>
|
|
||||||
props.data.map((item, i) => ({
|
|
||||||
name: item[props.index],
|
|
||||||
color: colors.value[i],
|
|
||||||
inactive: false
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
|
|
||||||
const totalValue = computed(() =>
|
|
||||||
props.data.reduce((prev, curr) => {
|
|
||||||
return prev + curr[props.category]
|
|
||||||
}, 0)
|
|
||||||
)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div :class="cn('w-full h-48 flex flex-col items-end', $attrs.class ?? '')">
|
|
||||||
<VisSingleContainer
|
|
||||||
:style="{ height: isMounted ? '100%' : 'auto' }"
|
|
||||||
:margin="{ left: 20, right: 20 }"
|
|
||||||
:data="data"
|
|
||||||
>
|
|
||||||
<ChartSingleTooltip
|
|
||||||
:selector="Donut.selectors.segment"
|
|
||||||
:index="category"
|
|
||||||
:items="legendItems"
|
|
||||||
:value-formatter="valueFormatter"
|
|
||||||
:custom-tooltip="customTooltip"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<VisDonut
|
|
||||||
:value="(d) => d[category]"
|
|
||||||
:sort-function="sortFunction"
|
|
||||||
:color="colors"
|
|
||||||
:arc-width="type === 'donut' ? 20 : 0"
|
|
||||||
:show-background="false"
|
|
||||||
:central-label="type === 'donut' ? valueFormatter(totalValue) : ''"
|
|
||||||
:events="{
|
|
||||||
[Donut.selectors.segment]: {
|
|
||||||
click: (d, ev, i, elements) => {
|
|
||||||
if (d?.data?.[index] === activeSegmentKey) {
|
|
||||||
activeSegmentKey = undefined
|
|
||||||
elements.forEach((el) => (el.style.opacity = '1'))
|
|
||||||
} else {
|
|
||||||
activeSegmentKey = d?.data?.[index]
|
|
||||||
elements.forEach((el) => (el.style.opacity = `${filterOpacity}`))
|
|
||||||
elements[i].style.opacity = '1'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<slot />
|
|
||||||
</VisSingleContainer>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { default as DonutChart } from './DonutChart.vue'
|
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col items-center justify-center text-gray-600 dark:text-gray-300">
|
|
||||||
<span class="dot-loader">
|
<span class="dot-loader">
|
||||||
<span class="dot"></span>
|
<span class="dot"></span>
|
||||||
<span class="dot"></span>
|
<span class="dot"></span>
|
||||||
<span class="dot"></span>
|
<span class="dot"></span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import { SplitterResizeHandle, useForwardPropsEmits } from 'radix-vue'
|
|
||||||
import { DragHandleDots2Icon } from '@radix-icons/vue'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
id: { type: String, required: false },
|
|
||||||
hitAreaMargins: { type: Object, required: false },
|
|
||||||
tabindex: { type: Number, required: false },
|
|
||||||
disabled: { type: Boolean, required: false },
|
|
||||||
asChild: { type: Boolean, required: false },
|
|
||||||
as: { type: null, required: false },
|
|
||||||
class: { type: null, required: false },
|
|
||||||
withHandle: { type: Boolean, required: false }
|
|
||||||
})
|
|
||||||
const emits = defineEmits(['dragging'])
|
|
||||||
|
|
||||||
const delegatedProps = computed(() => {
|
|
||||||
const { class: _, ...delegated } = props
|
|
||||||
return delegated
|
|
||||||
})
|
|
||||||
|
|
||||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<SplitterResizeHandle
|
|
||||||
v-bind="forwarded"
|
|
||||||
:class="
|
|
||||||
cn(
|
|
||||||
'relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 [&[data-orientation=vertical]]:h-px [&[data-orientation=vertical]]:w-full [&[data-orientation=vertical]]:after:left-0 [&[data-orientation=vertical]]:after:h-1 [&[data-orientation=vertical]]:after:w-full [&[data-orientation=vertical]]:after:-translate-y-1/2 [&[data-orientation=vertical]]:after:translate-x-0 [&[data-orientation=vertical]>div]:rotate-90',
|
|
||||||
props.class
|
|
||||||
)
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<template v-if="props.withHandle">
|
|
||||||
<div class="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
|
|
||||||
<DragHandleDots2Icon class="h-2.5 w-2.5" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</SplitterResizeHandle>
|
|
||||||
</template>
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import { SplitterGroup, useForwardPropsEmits } from 'radix-vue'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
id: { type: [String, null], required: false },
|
|
||||||
autoSaveId: { type: [String, null], required: false },
|
|
||||||
direction: { type: String, required: true },
|
|
||||||
keyboardResizeBy: { type: [Number, null], required: false },
|
|
||||||
storage: { type: Object, required: false },
|
|
||||||
asChild: { type: Boolean, required: false },
|
|
||||||
as: { type: null, required: false },
|
|
||||||
class: { type: null, required: false }
|
|
||||||
})
|
|
||||||
const emits = defineEmits(['layout'])
|
|
||||||
|
|
||||||
const delegatedProps = computed(() => {
|
|
||||||
const { class: _, ...delegated } = props
|
|
||||||
return delegated
|
|
||||||
})
|
|
||||||
|
|
||||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<SplitterGroup
|
|
||||||
v-bind="forwarded"
|
|
||||||
:class="cn('flex h-full w-full data-[panel-group-direction=vertical]:flex-col', props.class)"
|
|
||||||
>
|
|
||||||
<slot />
|
|
||||||
</SplitterGroup>
|
|
||||||
</template>
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export { default as ResizablePanelGroup } from './ResizablePanelGroup.vue'
|
|
||||||
export { default as ResizableHandle } from './ResizableHandle.vue'
|
|
||||||
export { SplitterPanel as ResizablePanel } from 'radix-vue'
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import { ScrollAreaCorner, ScrollAreaRoot, ScrollAreaViewport } from 'radix-vue'
|
|
||||||
import ScrollBar from './ScrollBar.vue'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
type: { type: String, required: false },
|
|
||||||
dir: { type: String, required: false },
|
|
||||||
scrollHideDelay: { type: Number, required: false },
|
|
||||||
asChild: { type: Boolean, required: false },
|
|
||||||
as: { type: null, required: false },
|
|
||||||
class: { type: null, required: false }
|
|
||||||
})
|
|
||||||
|
|
||||||
const delegatedProps = computed(() => {
|
|
||||||
const { class: _, ...delegated } = props
|
|
||||||
|
|
||||||
return delegated
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<ScrollAreaRoot v-bind="delegatedProps" :class="cn('relative overflow-hidden', props.class)">
|
|
||||||
<ScrollAreaViewport class="h-full w-full rounded-[inherit]">
|
|
||||||
<slot />
|
|
||||||
</ScrollAreaViewport>
|
|
||||||
<ScrollBar />
|
|
||||||
<ScrollAreaCorner />
|
|
||||||
</ScrollAreaRoot>
|
|
||||||
</template>
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import { ScrollAreaScrollbar, ScrollAreaThumb } from 'radix-vue'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
orientation: { type: String, required: false, default: 'vertical' },
|
|
||||||
forceMount: { type: Boolean, required: false },
|
|
||||||
asChild: { type: Boolean, required: false },
|
|
||||||
as: { type: null, required: false },
|
|
||||||
class: { type: null, required: false }
|
|
||||||
})
|
|
||||||
|
|
||||||
const delegatedProps = computed(() => {
|
|
||||||
const { class: _, ...delegated } = props
|
|
||||||
|
|
||||||
return delegated
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<ScrollAreaScrollbar
|
|
||||||
v-bind="delegatedProps"
|
|
||||||
:class="
|
|
||||||
cn(
|
|
||||||
'flex touch-none select-none transition-colors',
|
|
||||||
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-px',
|
|
||||||
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-px',
|
|
||||||
props.class
|
|
||||||
)
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<ScrollAreaThumb class="relative flex-1 rounded-full bg-border" />
|
|
||||||
</ScrollAreaScrollbar>
|
|
||||||
</template>
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { default as ScrollArea } from './ScrollArea.vue'
|
|
||||||
export { default as ScrollBar } from './ScrollBar.vue'
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { computed, provide } from 'vue'
|
|
||||||
import { ToggleGroupRoot, useForwardPropsEmits } from 'radix-vue'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
rovingFocus: { type: Boolean, required: false },
|
|
||||||
disabled: { type: Boolean, required: false },
|
|
||||||
orientation: { type: String, required: false },
|
|
||||||
dir: { type: String, required: false },
|
|
||||||
loop: { type: Boolean, required: false },
|
|
||||||
asChild: { type: Boolean, required: false },
|
|
||||||
as: { type: null, required: false },
|
|
||||||
type: { type: null, required: false },
|
|
||||||
modelValue: { type: null, required: false },
|
|
||||||
defaultValue: { type: null, required: false },
|
|
||||||
class: { type: null, required: false },
|
|
||||||
variant: { type: null, required: false },
|
|
||||||
size: { type: null, required: false }
|
|
||||||
})
|
|
||||||
const emits = defineEmits(['update:modelValue'])
|
|
||||||
|
|
||||||
provide('toggleGroup', {
|
|
||||||
variant: props.variant,
|
|
||||||
size: props.size
|
|
||||||
})
|
|
||||||
|
|
||||||
const delegatedProps = computed(() => {
|
|
||||||
const { class: _, ...delegated } = props
|
|
||||||
return delegated
|
|
||||||
})
|
|
||||||
|
|
||||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<ToggleGroupRoot
|
|
||||||
v-bind="forwarded"
|
|
||||||
:class="cn('flex items-center justify-center gap-1', props.class)"
|
|
||||||
>
|
|
||||||
<slot />
|
|
||||||
</ToggleGroupRoot>
|
|
||||||
</template>
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { computed, inject } from 'vue'
|
|
||||||
import { ToggleGroupItem, useForwardProps } from 'radix-vue'
|
|
||||||
import { toggleVariants } from '@/components/ui/toggle'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
value: { type: String, required: true },
|
|
||||||
defaultValue: { type: Boolean, required: false },
|
|
||||||
pressed: { type: Boolean, required: false },
|
|
||||||
disabled: { type: Boolean, required: false },
|
|
||||||
asChild: { type: Boolean, required: false },
|
|
||||||
as: { type: null, required: false },
|
|
||||||
class: { type: null, required: false },
|
|
||||||
variant: { type: null, required: false },
|
|
||||||
size: { type: null, required: false }
|
|
||||||
})
|
|
||||||
|
|
||||||
const context = inject('toggleGroup')
|
|
||||||
|
|
||||||
const delegatedProps = computed(() => {
|
|
||||||
const { class: _, variant, size, ...delegated } = props
|
|
||||||
return delegated
|
|
||||||
})
|
|
||||||
|
|
||||||
const forwardedProps = useForwardProps(delegatedProps)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<ToggleGroupItem
|
|
||||||
v-bind="forwardedProps"
|
|
||||||
:class="
|
|
||||||
cn(
|
|
||||||
toggleVariants({
|
|
||||||
variant: context?.variant || variant,
|
|
||||||
size: context?.size || size
|
|
||||||
}),
|
|
||||||
props.class
|
|
||||||
)
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<slot />
|
|
||||||
</ToggleGroupItem>
|
|
||||||
</template>
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { default as ToggleGroup } from './ToggleGroup.vue'
|
|
||||||
export { default as ToggleGroupItem } from './ToggleGroupItem.vue'
|
|
||||||
25
frontend/src/components/update/AppUpdate.vue
Normal file
25
frontend/src/components/update/AppUpdate.vue
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="appSettingsStore.settings['app.update']?.update?.is_new"
|
||||||
|
class="p-2 mb-2 border-b bg-secondary text-secondary-foreground"
|
||||||
|
>
|
||||||
|
A new update is available:
|
||||||
|
{{ appSettingsStore.settings['app.update'].update.release_version }} ({{
|
||||||
|
appSettingsStore.settings['app.update'].update.release_date
|
||||||
|
}})
|
||||||
|
<a
|
||||||
|
:href="appSettingsStore.settings['app.update'].update.url"
|
||||||
|
target="_blank"
|
||||||
|
nofollow
|
||||||
|
noreferrer
|
||||||
|
class="underline ml-2"
|
||||||
|
>
|
||||||
|
View details
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||||
|
const appSettingsStore = useAppSettingsStore()
|
||||||
|
</script>
|
||||||
43
frontend/src/composables/useIdleDetection.js
Normal file
43
frontend/src/composables/useIdleDetection.js
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -8,8 +8,6 @@ export const CONVERSATION_LIST_TYPE = {
|
|||||||
|
|
||||||
export const CONVERSATION_DEFAULT_STATUSES = {
|
export const CONVERSATION_DEFAULT_STATUSES = {
|
||||||
OPEN: 'Open',
|
OPEN: 'Open',
|
||||||
IN_PROGRESS: 'In Progress',
|
|
||||||
WAITING: 'Waiting',
|
|
||||||
SNOOZED: 'Snoozed',
|
SNOOZED: 'Snoozed',
|
||||||
RESOLVED: 'Resolved',
|
RESOLVED: 'Resolved',
|
||||||
CLOSED: 'Closed',
|
CLOSED: 'Closed',
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ export const adminNavItems = [
|
|||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
title: 'SSO',
|
title: 'SSO',
|
||||||
href: '/admin/oidc',
|
href: '/admin/sso',
|
||||||
permission: 'oidc:manage'
|
permission: 'oidc:manage'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -120,7 +120,7 @@
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
<Button type="submit" :disabled="isLoading">{{ submitLabel }}</Button>
|
<Button type="submit" :disabled="isLoading" :isLoading="isLoading">{{ submitLabel }}</Button>
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<form
|
<form @submit="onSubmit" class="space-y-6 w-full">
|
||||||
@submit="onSubmit"
|
|
||||||
class="space-y-6 w-full"
|
|
||||||
>
|
|
||||||
<FormField v-slot="{ field }" name="site_name">
|
<FormField v-slot="{ field }" name="site_name">
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Site Name</FormLabel>
|
<FormLabel>Site Name</FormLabel>
|
||||||
@@ -126,11 +123,15 @@
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField name="allowed_file_upload_extensions" v-slot="{ componentField }">
|
|
||||||
|
<FormField name="allowed_file_upload_extensions" v-slot="{ componentField, handleChange }">
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Allowed file upload extensions</FormLabel>
|
<FormLabel>Allowed file upload extensions</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<TagsInput v-model="componentField.modelValue">
|
<TagsInput
|
||||||
|
:modelValue="componentField.modelValue"
|
||||||
|
@update:modelValue="handleChange"
|
||||||
|
>
|
||||||
<TagsInputItem v-for="item in componentField.modelValue" :key="item" :value="item">
|
<TagsInputItem v-for="item in componentField.modelValue" :key="item" :value="item">
|
||||||
<TagsInputItemText />
|
<TagsInputItemText />
|
||||||
<TagsInputItemDelete />
|
<TagsInputItemDelete />
|
||||||
@@ -142,6 +143,8 @@
|
|||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
|
|
||||||
<Button type="submit" :isLoading="formLoading"> {{ submitLabel }} </Button>
|
<Button type="submit" :isLoading="formLoading"> {{ submitLabel }} </Button>
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ export const formSchema = z.object({
|
|||||||
.min(1, {
|
.min(1, {
|
||||||
message: 'Max upload file size must be at least 1 MB.'
|
message: 'Max upload file size must be at least 1 MB.'
|
||||||
})
|
})
|
||||||
.max(30, {
|
.max(500, {
|
||||||
message: 'Max upload file size cannot exceed 30 MB.'
|
message: 'Max upload file size cannot exceed 500 MB.'
|
||||||
}),
|
}),
|
||||||
allowed_file_upload_extensions: z.array(z.string()).nullable().default([]).optional()
|
allowed_file_upload_extensions: z.array(z.string()).nullable().default([]).optional()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,7 +7,9 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent>
|
<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>
|
<DropdownMenuItem @click="() => (alertOpen = true)">Delete</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
@@ -49,12 +51,10 @@ import {
|
|||||||
AlertDialogTitle
|
AlertDialogTitle
|
||||||
} from '@/components/ui/alert-dialog'
|
} from '@/components/ui/alert-dialog'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import { useEmitter } from '@/composables/useEmitter'
|
import { useEmitter } from '@/composables/useEmitter'
|
||||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const emit = useEmitter()
|
const emit = useEmitter()
|
||||||
const alertOpen = ref(false)
|
const alertOpen = ref(false)
|
||||||
|
|
||||||
@@ -68,10 +68,6 @@ const props = defineProps({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function edit(id) {
|
|
||||||
router.push({ path: `/admin/oidc/${id}/edit` })
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
await api.deleteOIDC(props.role.id)
|
await api.deleteOIDC(props.role.id)
|
||||||
alertOpen.value = false
|
alertOpen.value = false
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<Dialog v-model:open="dialogOpen">
|
<Dialog v-model:open="dialogOpen">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger as-child>
|
||||||
as-child
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
class="w-8 h-8 p-0"
|
||||||
v-if="!CONVERSATION_DEFAULT_STATUSES_LIST.includes(props.status.name)"
|
v-if="!CONVERSATION_DEFAULT_STATUSES_LIST.includes(props.status.name)"
|
||||||
>
|
>
|
||||||
<Button variant="ghost" class="w-8 h-8 p-0">
|
|
||||||
<span class="sr-only">Open menu</span>
|
<span class="sr-only">Open menu</span>
|
||||||
<MoreHorizontal class="w-4 h-4" />
|
<MoreHorizontal class="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<div v-else class="w-8 h-8 p-0 invisible"></div>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent>
|
<DropdownMenuContent>
|
||||||
<DialogTrigger as-child>
|
<DialogTrigger as-child>
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col w-full">
|
<div class="flex flex-col h-screen">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="p-2 border-b flex items-center justify-between">
|
<div class="h-12 flex-shrink-0 px-2 border-b flex items-center justify-between">
|
||||||
<div class="flex items-center space-x-3 pr-5">
|
<div>
|
||||||
|
<span v-if="!conversationStore.conversation.loading">
|
||||||
{{ conversationStore.currentContactName }}
|
{{ conversationStore.currentContactName }}
|
||||||
|
</span>
|
||||||
|
<Skeleton class="w-[130px] h-6" v-else />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<div>
|
<div>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger>
|
<DropdownMenuTrigger>
|
||||||
<div
|
<div
|
||||||
class="flex items-center space-x-1 cursor-pointer bg-primary px-2 py-1 rounded-md text-sm"
|
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
|
<span class="text-secondary font-medium inline-block">
|
||||||
class="text-secondary font-medium inline-block"
|
|
||||||
v-if="conversationStore.current?.status"
|
|
||||||
>
|
|
||||||
{{ conversationStore.current?.status }}
|
{{ conversationStore.current?.status }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else class="text-secondary font-medium inline-block"> Loading... </span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Skeleton class="w-[70px] h-6 rounded-full" v-else />
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent>
|
<DropdownMenuContent>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
@@ -33,18 +33,15 @@
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Messages & reply box -->
|
<!-- Messages & reply box -->
|
||||||
<div>
|
<div class="flex flex-col flex-grow overflow-hidden">
|
||||||
<div class="flex flex-col h-screen">
|
|
||||||
<MessageList class="flex-1 overflow-y-auto" />
|
<MessageList class="flex-1 overflow-y-auto" />
|
||||||
<div class="sticky bottom-0">
|
<div class="sticky bottom-0">
|
||||||
<ReplyBox class="h-max" />
|
<ReplyBox class="h-max" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -60,6 +57,7 @@ import ReplyBox from './ReplyBox.vue'
|
|||||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||||
import { CONVERSATION_DEFAULT_STATUSES } from '@/constants/conversation'
|
import { CONVERSATION_DEFAULT_STATUSES } from '@/constants/conversation'
|
||||||
import { useEmitter } from '@/composables/useEmitter'
|
import { useEmitter } from '@/composables/useEmitter'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
const conversationStore = useConversationStore()
|
const conversationStore = useConversationStore()
|
||||||
const emitter = useEmitter()
|
const emitter = useEmitter()
|
||||||
|
|
||||||
|
|||||||
@@ -120,6 +120,7 @@
|
|||||||
:handleInlineImageUpload="handleInlineImageUpload"
|
:handleInlineImageUpload="handleInlineImageUpload"
|
||||||
:isBold="isBold"
|
:isBold="isBold"
|
||||||
:isItalic="isItalic"
|
:isItalic="isItalic"
|
||||||
|
:isSending="isSending"
|
||||||
@toggleBold="toggleBold"
|
@toggleBold="toggleBold"
|
||||||
@toggleItalic="toggleItalic"
|
@toggleItalic="toggleItalic"
|
||||||
:enableSend="enableSend"
|
:enableSend="enableSend"
|
||||||
@@ -240,6 +241,7 @@
|
|||||||
:handleInlineImageUpload="handleInlineImageUpload"
|
:handleInlineImageUpload="handleInlineImageUpload"
|
||||||
:isBold="isBold"
|
:isBold="isBold"
|
||||||
:isItalic="isItalic"
|
:isItalic="isItalic"
|
||||||
|
:isSending="isSending"
|
||||||
@toggleBold="toggleBold"
|
@toggleBold="toggleBold"
|
||||||
@toggleItalic="toggleItalic"
|
@toggleItalic="toggleItalic"
|
||||||
:enableSend="enableSend"
|
:enableSend="enableSend"
|
||||||
@@ -276,6 +278,7 @@ const insertContent = ref(null)
|
|||||||
const setInlineImage = ref(null)
|
const setInlineImage = ref(null)
|
||||||
const clearEditorContent = ref(false)
|
const clearEditorContent = ref(false)
|
||||||
const isEditorFullscreen = ref(false)
|
const isEditorFullscreen = ref(false)
|
||||||
|
const isSending = ref(false)
|
||||||
const cursorPosition = ref(0)
|
const cursorPosition = ref(0)
|
||||||
const selectedText = ref('')
|
const selectedText = ref('')
|
||||||
const htmlContent = ref('')
|
const htmlContent = ref('')
|
||||||
@@ -464,6 +467,8 @@ const handleSend = async () => {
|
|||||||
|
|
||||||
isEditorFullscreen.value = false
|
isEditorFullscreen.value = false
|
||||||
try {
|
try {
|
||||||
|
isSending.value = true
|
||||||
|
|
||||||
// Send message if there is text content in the editor.
|
// Send message if there is text content in the editor.
|
||||||
if (hasTextContent.value) {
|
if (hasTextContent.value) {
|
||||||
// Replace inline image url with cid.
|
// Replace inline image url with cid.
|
||||||
@@ -517,6 +522,7 @@ const handleSend = async () => {
|
|||||||
description: handleHTTPError(error).message
|
description: handleHTTPError(error).message
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
|
isSending.value = false
|
||||||
clearEditorContent.value = true
|
clearEditorContent.value = true
|
||||||
conversationStore.resetMacro()
|
conversationStore.resetMacro()
|
||||||
conversationStore.resetMediaFiles()
|
conversationStore.resetMediaFiles()
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
<Smile class="h-4 w-4" />
|
<Smile class="h-4 w-4" />
|
||||||
</Toggle>
|
</Toggle>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -57,6 +57,7 @@ const emit = defineEmits(['toggleBold', 'toggleItalic', 'emojiSelect'])
|
|||||||
defineProps({
|
defineProps({
|
||||||
isBold: Boolean,
|
isBold: Boolean,
|
||||||
isItalic: Boolean,
|
isItalic: Boolean,
|
||||||
|
isSending: Boolean,
|
||||||
enableSend: Boolean,
|
enableSend: Boolean,
|
||||||
handleSend: Function,
|
handleSend: Function,
|
||||||
handleFileUpload: Function,
|
handleFileUpload: Function,
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="h-screen flex flex-col">
|
<div class="h-screen flex flex-col">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<header class="border-b">
|
<div class="flex items-center space-x-4 px-2 h-12 border-b shrink-0">
|
||||||
<div class="flex items-center space-x-4 p-2">
|
|
||||||
<SidebarTrigger class="h-4 w-4" />
|
<SidebarTrigger class="h-4 w-4" />
|
||||||
<span class="text-xl font-semibold text-gray-800">{{ title }}</span>
|
<span class="text-xl font-semibold text-gray-800">{{ title }}</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- 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>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" class="w-30">
|
<Button variant="ghost" class="w-30">
|
||||||
@@ -107,7 +105,7 @@
|
|||||||
|
|
||||||
<!-- Loading Skeleton -->
|
<!-- Loading Skeleton -->
|
||||||
<div v-if="isLoading" key="loading" class="space-y-4">
|
<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>
|
</div>
|
||||||
</TransitionGroup>
|
</TransitionGroup>
|
||||||
|
|
||||||
@@ -126,7 +124,9 @@
|
|||||||
<Loader2 v-if="isLoading" class="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 v-if="isLoading" class="mr-2 h-4 w-4 animate-spin" />
|
||||||
{{ isLoading ? 'Loading...' : 'Load more' }}
|
{{ isLoading ? 'Loading...' : 'Load more' }}
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -39,10 +39,14 @@
|
|||||||
|
|
||||||
<!-- Message preview and unread count -->
|
<!-- Message preview and unread count -->
|
||||||
<div class="flex items-start justify-between gap-2">
|
<div class="flex items-start justify-between gap-2">
|
||||||
<p class="text-sm text-gray-600 line-clamp-2 flex-1">
|
<div class="text-sm text-gray-600 flex items-center gap-1.5 flex-1 break-all">
|
||||||
<Reply class="inline-block w-4 h-4 mr-1.5 text-green-600 flex-shrink-0" />
|
<Reply
|
||||||
|
class="text-green-600 flex-shrink-0"
|
||||||
|
size="15"
|
||||||
|
v-if="conversation.last_message_sender === 'agent'"
|
||||||
|
/>
|
||||||
{{ trimmedLastMessage }}
|
{{ trimmedLastMessage }}
|
||||||
</p>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="conversation.unread_message_count > 0"
|
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"
|
class="flex items-center justify-center w-6 h-6 bg-green-600 text-white text-xs font-medium rounded-full"
|
||||||
|
|||||||
@@ -25,12 +25,12 @@
|
|||||||
<MessageAttachmentPreview :attachments="nonInlineAttachments" />
|
<MessageAttachmentPreview :attachments="nonInlineAttachments" />
|
||||||
|
|
||||||
<!-- Spinner for Pending Messages -->
|
<!-- 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 -->
|
<!-- Icons -->
|
||||||
<div class="flex items-center space-x-2 mt-2">
|
<div class="flex items-center space-x-2 mt-2">
|
||||||
<Lock :size="10" v-if="isPrivateMessage" class="text-muted-foreground" />
|
<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
|
<RotateCcw
|
||||||
size="10"
|
size="10"
|
||||||
@click="retryMessage(message)"
|
@click="retryMessage(message)"
|
||||||
@@ -69,7 +69,7 @@
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import { useConversationStore } from '@/stores/conversation'
|
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 { revertCIDToImageSrc } from '@/utils/strings'
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import { Spinner } from '@/components/ui/spinner'
|
import { Spinner } from '@/components/ui/spinner'
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col relative h-full">
|
<div class="flex flex-col relative h-full">
|
||||||
<div ref="threadEl" class="flex-1 overflow-y-auto" @scroll="handleScroll">
|
<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
|
<div
|
||||||
class="text-center mt-3"
|
class="text-center mt-3"
|
||||||
v-if="
|
v-if="
|
||||||
@@ -20,11 +20,13 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<MessagesSkeleton :count="10" v-if="conversationStore.messages.loading" />
|
||||||
|
|
||||||
<TransitionGroup
|
<TransitionGroup
|
||||||
|
v-else
|
||||||
enter-active-class="animate-slide-in"
|
enter-active-class="animate-slide-in"
|
||||||
tag="div"
|
tag="div"
|
||||||
class="space-y-4"
|
class="space-y-4"
|
||||||
v-if="!conversationStore.messages.loading"
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="message in conversationStore.conversationMessages"
|
v-for="message in conversationStore.conversationMessages"
|
||||||
@@ -43,7 +45,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TransitionGroup>
|
</TransitionGroup>
|
||||||
<MessagesSkeleton :count="20" v-else />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
<template>
|
<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">
|
<TransitionGroup name="attachment-list" tag="div" class="flex flex-wrap gap-2">
|
||||||
<div
|
<div
|
||||||
v-for="attachment in allAttachments"
|
v-for="attachment in allAttachments"
|
||||||
:key="attachment.uuid || attachment.tempId"
|
: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">
|
<div class="flex items-center space-x-1 py-1">
|
||||||
<span v-if="attachment.loading" class="dot-loader">
|
<DotLoader v-if="attachment.loading"/>
|
||||||
<span class="dot"></span>
|
|
||||||
<span class="dot"></span>
|
|
||||||
<span class="dot"></span>
|
|
||||||
</span>
|
|
||||||
<PaperclipIcon v-else size="16" class="text-gray-500 group-hover:text-primary" />
|
<PaperclipIcon v-else size="16" class="text-gray-500 group-hover:text-primary" />
|
||||||
|
|
||||||
<Tooltip>
|
<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"
|
class="max-w-[12rem] overflow-hidden text-ellipsis whitespace-nowrap text-sm font-medium text-primary group-hover:text-gray-900"
|
||||||
>
|
>
|
||||||
{{ getAttachmentName(attachment.filename) }}
|
{{ getAttachmentName(attachment.filename) }}
|
||||||
|
<span class="text-xs text-gray-500 ml-1">
|
||||||
|
{{ formatBytes(attachment.size) }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p class="text-sm">{{ attachment.filename }}</p>
|
<p class="text-sm">{{ attachment.filename }}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<span class="text-xs text-gray-500">
|
|
||||||
{{ formatBytes(attachment.size) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
v-if="!attachment.loading"
|
v-if="!attachment.loading"
|
||||||
@click.stop="onDelete(attachment.uuid)"
|
@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"
|
title="Remove attachment"
|
||||||
>
|
>
|
||||||
<X size="14" />
|
<X size="14" />
|
||||||
@@ -49,6 +44,7 @@
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { formatBytes } from '@/utils/file.js'
|
import { formatBytes } from '@/utils/file.js'
|
||||||
import { X, Paperclip as PaperclipIcon } from 'lucide-vue-next'
|
import { X, Paperclip as PaperclipIcon } from 'lucide-vue-next'
|
||||||
|
import { DotLoader } from '@/components/ui/loader'
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -85,13 +81,13 @@ const getAttachmentName = (name) => {
|
|||||||
.attachment-list-move,
|
.attachment-list-move,
|
||||||
.attachment-list-enter-active,
|
.attachment-list-enter-active,
|
||||||
.attachment-list-leave-active {
|
.attachment-list-leave-active {
|
||||||
transition: all 0.5s ease;
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.attachment-list-enter-from,
|
.attachment-list-enter-from,
|
||||||
.attachment-list-leave-to {
|
.attachment-list-leave-to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateX(30px);
|
transform: translateX(10px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.attachment-list-leave-active {
|
.attachment-list-leave-active {
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
<template>
|
|
||||||
hi
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup></script>
|
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
collapsible
|
collapsible
|
||||||
:default-value="['Actions', 'Information', 'Previous conversations']"
|
: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">
|
<AccordionTrigger class="bg-muted px-4 py-3 text-sm font-medium rounded-lg mx-2">
|
||||||
Actions
|
Actions
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
|
|||||||
@@ -16,16 +16,16 @@
|
|||||||
size="16"
|
size="16"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<h4 class="mt-3">
|
<div class="mt-3 h-6">
|
||||||
<span v-if="conversationStore.conversation.loading">
|
<span v-if="conversationStore.conversation.loading">
|
||||||
<Skeleton class="w-32 h-4" />
|
<Skeleton class="w-24 h-4" />
|
||||||
</span>
|
</span>
|
||||||
<span v-else>
|
<span v-else>
|
||||||
{{ conversation?.contact?.first_name + ' ' + conversation?.contact?.last_name }}
|
{{ conversation?.contact?.first_name + ' ' + conversation?.contact?.last_name }}
|
||||||
</span>
|
</span>
|
||||||
</h4>
|
</div>
|
||||||
<p class="text-sm text-muted-foreground flex gap-2 mt-1">
|
<div class="text-sm text-muted-foreground flex gap-2 h-4 mt-2">
|
||||||
<Mail class="size-3 mt-1" />
|
<Mail class="size-3 mt-1" />
|
||||||
<span v-if="conversationStore.conversation.loading">
|
<span v-if="conversationStore.conversation.loading">
|
||||||
<Skeleton class="w-32 h-4" />
|
<Skeleton class="w-32 h-4" />
|
||||||
@@ -33,8 +33,8 @@
|
|||||||
<span v-else>
|
<span v-else>
|
||||||
{{ conversation?.contact?.email }}
|
{{ conversation?.contact?.email }}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</div>
|
||||||
<p class="text-sm text-muted-foreground flex gap-2 mt-1">
|
<div class="text-sm text-muted-foreground flex gap-2 mt-2 h-4">
|
||||||
<Phone class="size-3 mt-1" />
|
<Phone class="size-3 mt-1" />
|
||||||
<span v-if="conversationStore.conversation.loading">
|
<span v-if="conversationStore.conversation.loading">
|
||||||
<Skeleton class="w-32 h-4" />
|
<Skeleton class="w-32 h-4" />
|
||||||
@@ -42,7 +42,6 @@
|
|||||||
<span v-else>
|
<span v-else>
|
||||||
{{ conversation?.contact?.phone_number || 'Not available' }}
|
{{ conversation?.contact?.phone_number || 'Not available' }}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,25 +1,36 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-1 flex-col gap-x-5 box p-5 space-y-5 bg-white">
|
<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">
|
<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">
|
<div class="bg-green-100/70 flex items-center space-x-2 px-1 rounded">
|
||||||
<span class="blinking-dot"></span>
|
<span class="blinking-dot"></span>
|
||||||
<p class="uppercase text-xs">Live</p>
|
<p class="uppercase text-xs">Live</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between pr-32">
|
<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-muted-foreground">{{ labels[key] }}</span>
|
||||||
<span class="text-2xl font-medium">{{ value }}</span>
|
<span class="text-2xl font-medium">{{ item }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
defineProps({
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
counts: { type: Object, required: true },
|
counts: { type: Object, required: true },
|
||||||
labels: { type: Object, required: true },
|
labels: { type: Object, required: true },
|
||||||
title: { type: String, 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>
|
</script>
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex flex-col space-y-6" v-if="userStore.getFullName">
|
|
||||||
<div>
|
|
||||||
<span class="font-medium text-xl space-y-1">
|
|
||||||
<p class="font-semibold text-2xl">Hi, {{ userStore.getFullName }}</p>
|
|
||||||
<p class="text-muted-foreground text-lg">🌤️ {{ format(new Date(), 'EEEE, MMMM d, HH:mm a') }}</p>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { format } from 'date-fns'
|
|
||||||
import { useUserStore } from '@/stores/user'
|
|
||||||
const userStore = useUserStore()
|
|
||||||
</script>
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<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" />
|
<SidebarTrigger class="cursor-pointer w-4 h-4 text-black" />
|
||||||
<div class="flex-1 flex items-center">
|
<div class="flex-1 flex items-center">
|
||||||
<Search class="w-5 h-5" />
|
<Search class="w-5 h-5" />
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-4 md:block page-content">
|
<div class="overflow-y-auto h-screen">
|
||||||
<div class="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
|
<div class="p-6 sm:p-8 min-h-full flex flex-col">
|
||||||
<div class="flex-1 lg:max-w-3xl min-h-[700px]">
|
<router-view class="flex-grow" />
|
||||||
<div class="space-y-6">
|
|
||||||
<router-view />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup></script>
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-content">
|
<div class="overflow-y-auto h-screen">
|
||||||
<div class="p-6 sm:p-8">
|
<div class="p-6 sm:p-8 min-h-full flex flex-col">
|
||||||
<router-view />
|
<router-view class="flex-grow" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between h-full">
|
||||||
<div class="w-8/12">
|
<div class="w-8/12">
|
||||||
<slot name="content" />
|
<slot name="content" />
|
||||||
</div>
|
</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" />
|
<slot name="help" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
import { createI18n } from 'vue-i18n'
|
import { createI18n } from 'vue-i18n'
|
||||||
|
import { useAppSettingsStore } from './stores/appSettings'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
import mitt from 'mitt'
|
import mitt from 'mitt'
|
||||||
import api from './api'
|
import api from './api'
|
||||||
@@ -38,12 +39,16 @@ async function initApp () {
|
|||||||
const i18n = createI18n(i18nConfig)
|
const i18n = createI18n(i18nConfig)
|
||||||
const app = createApp(Root)
|
const app = createApp(Root)
|
||||||
const pinia = createPinia()
|
const pinia = createPinia()
|
||||||
|
app.use(pinia)
|
||||||
|
|
||||||
|
// Store app settings in Pinia
|
||||||
|
const settingsStore = useAppSettingsStore()
|
||||||
|
settingsStore.setSettings(settings)
|
||||||
|
|
||||||
// Add emitter to global properties.
|
// Add emitter to global properties.
|
||||||
app.config.globalProperties.emitter = emitter
|
app.config.globalProperties.emitter = emitter
|
||||||
|
|
||||||
app.use(router)
|
app.use(router)
|
||||||
app.use(pinia)
|
|
||||||
app.use(i18n)
|
app.use(i18n)
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -174,6 +174,7 @@ const routes = [
|
|||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
|
name: 'business-hours-list',
|
||||||
component: () => import('@/views/admin/business-hours/BusinessHoursList.vue'),
|
component: () => import('@/views/admin/business-hours/BusinessHoursList.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -198,16 +199,19 @@ const routes = [
|
|||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
|
name: 'sla-list',
|
||||||
component: () => import('@/views/admin/sla/SLAList.vue'),
|
component: () => import('@/views/admin/sla/SLAList.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'new',
|
path: 'new',
|
||||||
|
name: 'new-sla',
|
||||||
component: () => import('@/views/admin/sla/CreateEditSLA.vue'),
|
component: () => import('@/views/admin/sla/CreateEditSLA.vue'),
|
||||||
meta: { title: 'New SLA' }
|
meta: { title: 'New SLA' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: ':id/edit',
|
path: ':id/edit',
|
||||||
props: true,
|
props: true,
|
||||||
|
name: 'edit-sla',
|
||||||
component: () => import('@/views/admin/sla/CreateEditSLA.vue'),
|
component: () => import('@/views/admin/sla/CreateEditSLA.vue'),
|
||||||
meta: { title: 'Edit SLA' }
|
meta: { title: 'Edit SLA' }
|
||||||
},
|
},
|
||||||
@@ -220,6 +224,7 @@ const routes = [
|
|||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
|
name: 'inbox-list',
|
||||||
component: () => import('@/views/admin/inbox/InboxList.vue'),
|
component: () => import('@/views/admin/inbox/InboxList.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -253,6 +258,7 @@ const routes = [
|
|||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
|
name: 'user-list',
|
||||||
component: () => import('@/views/admin/users/UserList.vue'),
|
component: () => import('@/views/admin/users/UserList.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -276,16 +282,19 @@ const routes = [
|
|||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
|
name: 'team-list',
|
||||||
component: () => import('@/views/admin/teams/TeamList.vue'),
|
component: () => import('@/views/admin/teams/TeamList.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'new',
|
path: 'new',
|
||||||
|
name: 'new-team',
|
||||||
component: () => import('@/views/admin/teams/CreateTeamForm.vue'),
|
component: () => import('@/views/admin/teams/CreateTeamForm.vue'),
|
||||||
meta: { title: 'Create Team' }
|
meta: { title: 'Create Team' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: ':id/edit',
|
path: ':id/edit',
|
||||||
props: true,
|
props: true,
|
||||||
|
name: 'edit-team',
|
||||||
component: () => import('@/views/admin/teams/EditTeamForm.vue'),
|
component: () => import('@/views/admin/teams/EditTeamForm.vue'),
|
||||||
meta: { title: 'Edit Team' }
|
meta: { title: 'Edit Team' }
|
||||||
},
|
},
|
||||||
@@ -298,16 +307,19 @@ const routes = [
|
|||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
|
name: 'role-list',
|
||||||
component: () => import('@/views/admin/roles/RoleList.vue'),
|
component: () => import('@/views/admin/roles/RoleList.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'new',
|
path: 'new',
|
||||||
|
name: 'new-role',
|
||||||
component: () => import('@/views/admin/roles/NewRole.vue'),
|
component: () => import('@/views/admin/roles/NewRole.vue'),
|
||||||
meta: { title: 'Create Role' }
|
meta: { title: 'Create Role' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: ':id/edit',
|
path: ':id/edit',
|
||||||
props: true,
|
props: true,
|
||||||
|
name: 'edit-role',
|
||||||
component: () => import('@/views/admin/roles/EditRole.vue'),
|
component: () => import('@/views/admin/roles/EditRole.vue'),
|
||||||
meta: { title: 'Edit Role' }
|
meta: { title: 'Edit Role' }
|
||||||
}
|
}
|
||||||
@@ -318,17 +330,20 @@ const routes = [
|
|||||||
{
|
{
|
||||||
path: 'automations',
|
path: 'automations',
|
||||||
component: () => import('@/views/admin/automations/Automation.vue'),
|
component: () => import('@/views/admin/automations/Automation.vue'),
|
||||||
|
name: 'automations',
|
||||||
meta: { title: 'Automations' },
|
meta: { title: 'Automations' },
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: 'new',
|
path: 'new',
|
||||||
props: true,
|
props: true,
|
||||||
|
name: 'new-automation',
|
||||||
component: () => import('@/views/admin/automations/CreateOrEditRule.vue'),
|
component: () => import('@/views/admin/automations/CreateOrEditRule.vue'),
|
||||||
meta: { title: 'Create Automation' }
|
meta: { title: 'Create Automation' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: ':id/edit',
|
path: ':id/edit',
|
||||||
props: true,
|
props: true,
|
||||||
|
name: 'edit-automation',
|
||||||
component: () => import('@/views/admin/automations/CreateOrEditRule.vue'),
|
component: () => import('@/views/admin/automations/CreateOrEditRule.vue'),
|
||||||
meta: { title: 'Edit Automation' }
|
meta: { title: 'Edit Automation' }
|
||||||
}
|
}
|
||||||
@@ -337,6 +352,7 @@ const routes = [
|
|||||||
{
|
{
|
||||||
path: 'templates',
|
path: 'templates',
|
||||||
component: () => import('@/views/admin/templates/Templates.vue'),
|
component: () => import('@/views/admin/templates/Templates.vue'),
|
||||||
|
name: 'templates',
|
||||||
meta: { title: 'Templates' },
|
meta: { title: 'Templates' },
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
@@ -356,22 +372,26 @@ const routes = [
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'oidc',
|
path: 'sso',
|
||||||
component: () => import('@/views/admin/oidc/OIDC.vue'),
|
component: () => import('@/views/admin/oidc/OIDC.vue'),
|
||||||
|
name: 'sso',
|
||||||
meta: { title: 'SSO' },
|
meta: { title: 'SSO' },
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
|
name: 'sso-list',
|
||||||
component: () => import('@/views/admin/oidc/OIDCList.vue'),
|
component: () => import('@/views/admin/oidc/OIDCList.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: ':id/edit',
|
path: ':id/edit',
|
||||||
props: true,
|
props: true,
|
||||||
|
name: 'edit-sso',
|
||||||
component: () => import('@/views/admin/oidc/CreateEditOIDC.vue'),
|
component: () => import('@/views/admin/oidc/CreateEditOIDC.vue'),
|
||||||
meta: { title: 'Edit SSO' }
|
meta: { title: 'Edit SSO' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'new',
|
path: 'new',
|
||||||
|
name: 'new-sso',
|
||||||
component: () => import('@/views/admin/oidc/CreateEditOIDC.vue'),
|
component: () => import('@/views/admin/oidc/CreateEditOIDC.vue'),
|
||||||
meta: { title: 'New SSO' }
|
meta: { title: 'New SSO' }
|
||||||
}
|
}
|
||||||
@@ -392,12 +412,13 @@ const routes = [
|
|||||||
meta: { title: 'Statuses' }
|
meta: { title: 'Statuses' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'Macros',
|
path: 'macros',
|
||||||
component: () => import('@/views/admin/macros/Macros.vue'),
|
component: () => import('@/views/admin/macros/Macros.vue'),
|
||||||
meta: { title: 'Macros' },
|
meta: { title: 'Macros' },
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
|
name: 'macro-list',
|
||||||
component: () => import('@/views/admin/macros/MacroList.vue'),
|
component: () => import('@/views/admin/macros/MacroList.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
12
frontend/src/stores/appSettings.js
Normal file
12
frontend/src/stores/appSettings.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
|
export const useAppSettingsStore = defineStore('settings', {
|
||||||
|
state: () => ({
|
||||||
|
settings: {}
|
||||||
|
}),
|
||||||
|
actions: {
|
||||||
|
setSettings (newSettings) {
|
||||||
|
this.settings = newSettings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -282,8 +282,10 @@ export const useConversationStore = defineStore('conversation', () => {
|
|||||||
async function fetchMessages (uuid, fetchNextPage = false) {
|
async function fetchMessages (uuid, fetchNextPage = false) {
|
||||||
// Messages are already cached?
|
// Messages are already cached?
|
||||||
let hasMessages = messages.data.getAllPagesMessages(uuid)
|
let hasMessages = messages.data.getAllPagesMessages(uuid)
|
||||||
if (hasMessages.length > 0 && !fetchNextPage)
|
if (hasMessages.length > 0 && !fetchNextPage) {
|
||||||
|
markConversationAsRead(uuid)
|
||||||
return
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch messages from server.
|
// Fetch messages from server.
|
||||||
messages.loading = true
|
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 response = await api.getConversationMessages(uuid, { page: page, page_size: MESSAGE_LIST_PAGE_SIZE })
|
||||||
const result = response.data?.data || {}
|
const result = response.data?.data || {}
|
||||||
const newMessages = result.results || []
|
const newMessages = result.results || []
|
||||||
// Mark conversation as read
|
|
||||||
markConversationAsRead(uuid)
|
markConversationAsRead(uuid)
|
||||||
// Cache messages
|
// Cache messages
|
||||||
messages.data.addMessages(uuid, newMessages, result.page, result.total_pages)
|
messages.data.addMessages(uuid, newMessages, result.page, result.total_pages)
|
||||||
@@ -545,10 +546,12 @@ export const useConversationStore = defineStore('conversation', () => {
|
|||||||
if (listConversation) {
|
if (listConversation) {
|
||||||
listConversation.last_message = message.content
|
listConversation.last_message = message.content
|
||||||
listConversation.last_message_at = message.created_at
|
listConversation.last_message_at = message.created_at
|
||||||
|
listConversation.last_message_sender = message.sender_type
|
||||||
if (listConversation.uuid !== conversation?.data?.uuid) {
|
if (listConversation.uuid !== conversation?.data?.uuid) {
|
||||||
listConversation.unread_message_count += 1
|
listConversation.unread_message_count += 1
|
||||||
}
|
}
|
||||||
} else {
|
} 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()
|
fetchFirstPageConversations()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,14 +15,15 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
avatar_url: '',
|
avatar_url: '',
|
||||||
email: '',
|
email: '',
|
||||||
teams: [],
|
teams: [],
|
||||||
permissions: []
|
permissions: [],
|
||||||
|
availability_status: 'offline'
|
||||||
})
|
})
|
||||||
const emitter = useEmitter()
|
const emitter = useEmitter()
|
||||||
|
|
||||||
const userID = computed(() => user.value.id)
|
const userID = computed(() => user.value.id)
|
||||||
const firstName = computed(() => user.value.first_name)
|
const firstName = computed(() => user.value.first_name || '')
|
||||||
const lastName = computed(() => user.value.last_name)
|
const lastName = computed(() => user.value.last_name || '')
|
||||||
const avatar = computed(() => user.value.avatar_url)
|
const avatar = computed(() => user.value.avatar_url || '')
|
||||||
const permissions = computed(() => user.value.permissions || [])
|
const permissions = computed(() => user.value.permissions || [])
|
||||||
const email = computed(() => user.value.email)
|
const email = computed(() => user.value.email)
|
||||||
const teams = computed(() => user.value.teams || [])
|
const teams = computed(() => user.value.teams || [])
|
||||||
@@ -71,6 +72,10 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setCurrentUser = (userData) => {
|
||||||
|
user.value = userData
|
||||||
|
}
|
||||||
|
|
||||||
const setAvatar = (avatarURL) => {
|
const setAvatar = (avatarURL) => {
|
||||||
if (typeof avatarURL !== 'string') {
|
if (typeof avatarURL !== 'string') {
|
||||||
console.warn('Avatar URL must be a string')
|
console.warn('Avatar URL must be a string')
|
||||||
@@ -83,6 +88,16 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
user.value.avatar_url = ''
|
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 {
|
return {
|
||||||
user,
|
user,
|
||||||
userID,
|
userID,
|
||||||
@@ -96,9 +111,11 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
getInitials,
|
getInitials,
|
||||||
hasAdminTabPermissions,
|
hasAdminTabPermissions,
|
||||||
hasReportTabPermissions,
|
hasReportTabPermissions,
|
||||||
|
setCurrentUser,
|
||||||
getCurrentUser,
|
getCurrentUser,
|
||||||
clearAvatar,
|
clearAvatar,
|
||||||
setAvatar,
|
setAvatar,
|
||||||
|
updateUserAvailability,
|
||||||
can
|
can
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
7
frontend/src/utils/debounce.js
Normal file
7
frontend/src/utils/debounce.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export function debounce (fn, delay) {
|
||||||
|
let timeout
|
||||||
|
return function (...args) {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
timeout = setTimeout(() => fn(...args), delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="p-5 w-screen h-screen">
|
<div class="h-full">
|
||||||
<div class="flex flex-col space-y-5">
|
<div class="flex flex-col space-y-5">
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<span class="sub-title">Public avatar</span>
|
<span class="sub-title">Public avatar</span>
|
||||||
<p class="text-muted-foreground text-xs">Change your avatar here.</p>
|
<p class="text-muted-foreground text-xs">Change your avatar here.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex space-x-5">
|
<div class="flex space-x-5">
|
||||||
<Avatar class="size-28 bg-white">
|
<Avatar class="size-28">
|
||||||
<AvatarImage :src="userStore.avatar" alt="Cropped Image" />
|
<AvatarImage :src="userStore.avatar" alt="Cropped Image" />
|
||||||
<AvatarFallback>{{ userStore.getInitials }}</AvatarFallback>
|
<AvatarFallback>{{ userStore.getInitials }}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<AdminPageWithHelp>
|
<AdminPageWithHelp>
|
||||||
<template #content>
|
<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="flex justify-between mb-5">
|
||||||
<div class="ml-auto">
|
<div class="ml-auto">
|
||||||
<Button @click="newRule">New rule</Button>
|
<Button @click="newRule">New rule</Button>
|
||||||
@@ -34,6 +34,6 @@ import AdminPageWithHelp from '@/layouts/admin/AdminPageWithHelp.vue'
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const selectedTab = useStorage('automationsTab', 'new_conversation')
|
const selectedTab = useStorage('automationsTab', 'new_conversation')
|
||||||
const newRule = () => {
|
const newRule = () => {
|
||||||
router.push({ path: `/admin/automations/new`, query: { type: selectedTab.value } })
|
router.push({ name: 'new-automation', query: { type: selectedTab.value } })
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -235,8 +235,8 @@ const isNewForm = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const breadcrumbLinks = [
|
const breadcrumbLinks = [
|
||||||
{ path: '/admin/automations', label: 'Automations' },
|
{ path: 'automations', label: 'Automations' },
|
||||||
{ path: '#', label: breadcrumbPageLabel() }
|
{ path: '', label: breadcrumbPageLabel() }
|
||||||
]
|
]
|
||||||
|
|
||||||
const firstRuleGroup = ref([])
|
const firstRuleGroup = ref([])
|
||||||
@@ -330,7 +330,7 @@ const handleSave = async (values) => {
|
|||||||
await api.updateAutomationRule(props.id, updatedRule)
|
await api.updateAutomationRule(props.id, updatedRule)
|
||||||
} else {
|
} else {
|
||||||
await api.createAutomationRule(updatedRule)
|
await api.createAutomationRule(updatedRule)
|
||||||
router.push('/admin/automations')
|
router.push({ name: 'automations' })
|
||||||
}
|
}
|
||||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||||
title: 'Success',
|
title: 'Success',
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ const submitForm = async (values) => {
|
|||||||
title: 'Success',
|
title: 'Success',
|
||||||
description: 'Business hours created successfully',
|
description: 'Business hours created successfully',
|
||||||
})
|
})
|
||||||
router.push('/admin/business-hours')
|
router.push({ name: 'business-hours-list' })
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||||
@@ -67,8 +67,8 @@ const isNewForm = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const breadcrumbLinks = [
|
const breadcrumbLinks = [
|
||||||
{ path: '/admin/business-hours', label: 'Business hours' },
|
{ path: 'business-hours-list', label: 'Business hours' },
|
||||||
{ path: '#', label: breadCrumLabel() }
|
{ path: '', label: breadCrumLabel() }
|
||||||
]
|
]
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
|||||||
@@ -11,10 +11,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #help>
|
<template #help>
|
||||||
<p>
|
<p>General settings for your support desk like timezone, working hours, etc.</p>
|
||||||
Configure core helpdesk settings like helpdesk name, timezone, business hours, and more.
|
|
||||||
</p>
|
|
||||||
<p>These settings affect your entire helpdesk system.</p>
|
|
||||||
</template>
|
</template>
|
||||||
</AdminPageWithHelp>
|
</AdminPageWithHelp>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ const formLoading = ref(false)
|
|||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const inbox = ref({})
|
const inbox = ref({})
|
||||||
const breadcrumbLinks = [
|
const breadcrumbLinks = [
|
||||||
{ path: '/admin/inboxes', label: 'Inboxes' },
|
{ path: 'inbox-list', label: 'Inboxes' },
|
||||||
{ path: '#', label: 'Edit Inbox' }
|
{ path: '', label: 'Edit Inbox' }
|
||||||
]
|
]
|
||||||
|
|
||||||
const submitForm = (values) => {
|
const submitForm = (values) => {
|
||||||
@@ -59,7 +59,7 @@ const updateInbox = async (payload) => {
|
|||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||||
title: 'Could not update inbox',
|
title: 'Error',
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
description: handleHTTPError(error).message
|
description: handleHTTPError(error).message
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -82,8 +82,8 @@ const currentStep = ref(1)
|
|||||||
const selectedChannel = ref(null)
|
const selectedChannel = ref(null)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const breadcrumbLinks = [
|
const breadcrumbLinks = [
|
||||||
{ path: '/admin/inboxes', label: 'Inboxes' },
|
{ path: 'inbox-list', label: 'Inboxes' },
|
||||||
{ path: '#', label: 'New Inbox' }
|
{ path: '', label: 'New Inbox' }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -149,7 +149,7 @@ async function createInbox (payload) {
|
|||||||
title: 'Success',
|
title: 'Success',
|
||||||
description: 'Inbox created successfully'
|
description: 'Inbox created successfully'
|
||||||
})
|
})
|
||||||
router.push('/admin/inboxes')
|
router.push({ name: 'inbox-list' })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||||
title: 'Could not create inbox',
|
title: 'Could not create inbox',
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ const router = useRouter()
|
|||||||
const emit = useEmitter()
|
const emit = useEmitter()
|
||||||
const formLoading = ref(false)
|
const formLoading = ref(false)
|
||||||
const breadcrumbLinks = [
|
const breadcrumbLinks = [
|
||||||
{ path: '/admin/conversations/macros', label: 'Macros' },
|
{ path: 'macro-list', label: 'Macros' },
|
||||||
{ path: '#', label: 'New macro' }
|
{ path: '', label: 'New macro' }
|
||||||
]
|
]
|
||||||
|
|
||||||
const onSubmit = (values) => {
|
const onSubmit = (values) => {
|
||||||
@@ -38,7 +38,7 @@ const createMacro = async (values) => {
|
|||||||
title: 'Success',
|
title: 'Success',
|
||||||
description: 'Macro created successfully'
|
description: 'Macro created successfully'
|
||||||
})
|
})
|
||||||
router.push('/admin/conversations/macros')
|
router.push({ name: 'macro-list' })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
emit.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
emit.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ const formLoading = ref(false)
|
|||||||
const emitter = useEmitter()
|
const emitter = useEmitter()
|
||||||
|
|
||||||
const breadcrumbLinks = [
|
const breadcrumbLinks = [
|
||||||
{ path: '/admin/conversations/macros', label: 'Macros' },
|
{ path: 'macro-list', label: 'Macros' },
|
||||||
{ path: '#', label: 'Edit macro' }
|
{ path: '', label: 'Edit macro' }
|
||||||
]
|
]
|
||||||
|
|
||||||
const submitForm = (values) => {
|
const submitForm = (values) => {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
</template>
|
</template>
|
||||||
<template #help>
|
<template #help>
|
||||||
<p>Combine multiple conversation actions into single-click macros.</p>
|
<p>Combine multiple conversation actions into single-click macros.</p>
|
||||||
<p>Create reusable action sets for common agent responses.</p>
|
|
||||||
</template>
|
</template>
|
||||||
</AdminPageWithHelp>
|
</AdminPageWithHelp>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -38,8 +38,23 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const submitForm = async (values) => {
|
const submitForm = async (values) => {
|
||||||
|
// Test the provider first.
|
||||||
try {
|
try {
|
||||||
formLoading.value = true
|
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 = ''
|
let toastDescription = ''
|
||||||
if (props.id) {
|
if (props.id) {
|
||||||
if (values.client_secret.includes('•')) {
|
if (values.client_secret.includes('•')) {
|
||||||
@@ -47,10 +62,10 @@ const submitForm = async (values) => {
|
|||||||
}
|
}
|
||||||
await api.updateOIDC(props.id, values)
|
await api.updateOIDC(props.id, values)
|
||||||
toastDescription = 'Provider updated successfully'
|
toastDescription = 'Provider updated successfully'
|
||||||
|
router.push({ name: 'sso-list' })
|
||||||
} else {
|
} else {
|
||||||
await api.createOIDC(values)
|
await api.createOIDC(values)
|
||||||
toastDescription = 'Provider created successfully'
|
toastDescription = 'Provider created successfully'
|
||||||
router.push('/admin/oidc')
|
|
||||||
}
|
}
|
||||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||||
title: 'Success',
|
title: 'Success',
|
||||||
@@ -84,8 +99,8 @@ const isNewForm = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const breadcrumbLinks = [
|
const breadcrumbLinks = [
|
||||||
{ path: '/admin/oidc', label: 'OIDC' },
|
{ path: 'sso-list', label: 'SSO' },
|
||||||
{ path: '#', label: breadCrumLabel() }
|
{ path: '', label: breadCrumLabel() }
|
||||||
]
|
]
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<AdminPageWithHelp>
|
<AdminPageWithHelp>
|
||||||
<template #content>
|
<template #content>
|
||||||
<router-view></router-view>
|
<router-view/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #help>
|
<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>
|
</template>
|
||||||
</AdminPageWithHelp>
|
</AdminPageWithHelp>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
<div class="flex justify-between mb-5">
|
<div class="flex justify-between mb-5">
|
||||||
<div></div>
|
<div></div>
|
||||||
<div>
|
<div>
|
||||||
<Button @click="navigateToNewOIDC">New OIDC</Button>
|
<RouterLink :to="{ name: 'new-sso' }">
|
||||||
|
<Button>New SSO</Button>
|
||||||
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -18,7 +20,6 @@ import { ref, onMounted, onUnmounted } from 'vue'
|
|||||||
import DataTable from '@/components/datatable/DataTable.vue'
|
import DataTable from '@/components/datatable/DataTable.vue'
|
||||||
import { columns } from '@/features/admin/oidc/dataTableColumns.js'
|
import { columns } from '@/features/admin/oidc/dataTableColumns.js'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { useEmitter } from '@/composables/useEmitter'
|
import { useEmitter } from '@/composables/useEmitter'
|
||||||
|
|
||||||
import { Spinner } from '@/components/ui/spinner'
|
import { Spinner } from '@/components/ui/spinner'
|
||||||
@@ -27,7 +28,6 @@ import api from '@/api'
|
|||||||
|
|
||||||
const oidc = ref([])
|
const oidc = ref([])
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const router = useRouter()
|
|
||||||
const emit = useEmitter()
|
const emit = useEmitter()
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -52,8 +52,4 @@ const fetchAll = async () => {
|
|||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const navigateToNewOIDC = () => {
|
|
||||||
router.push('/admin/oidc/new')
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ onMounted(async () => {
|
|||||||
|
|
||||||
const breadcrumbLinks = [
|
const breadcrumbLinks = [
|
||||||
|
|
||||||
{ path: '/admin/teams/roles', label: 'Roles' },
|
{ path: 'role-list', label: 'Roles' },
|
||||||
{ path: '#', label: 'Edit role' }
|
{ path: '', label: 'Edit role' }
|
||||||
]
|
]
|
||||||
|
|
||||||
const submitForm = async (values) => {
|
const submitForm = async (values) => {
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ const emitter = useEmitter()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const formLoading = ref(false)
|
const formLoading = ref(false)
|
||||||
const breadcrumbLinks = [
|
const breadcrumbLinks = [
|
||||||
{ path: '/admin/teams/roles', label: 'Roles' },
|
{ path: 'role-list', label: 'Roles' },
|
||||||
{ path: '#', label: 'Add role' }
|
{ path: '', label: 'New role' }
|
||||||
]
|
]
|
||||||
|
|
||||||
const submitForm = async (values) => {
|
const submitForm = async (values) => {
|
||||||
@@ -31,7 +31,7 @@ const submitForm = async (values) => {
|
|||||||
title: 'Success',
|
title: 'Success',
|
||||||
description: 'Role created successfully'
|
description: 'Role created successfully'
|
||||||
})
|
})
|
||||||
router.push('/admin/teams/roles')
|
router.push({ name: 'role-list' })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||||
title: 'Could not create role',
|
title: 'Could not create role',
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
<Spinner v-if="isLoading" />
|
<Spinner v-if="isLoading" />
|
||||||
<div :class="{ 'transition-opacity duration-300 opacity-50': isLoading }">
|
<div :class="{ 'transition-opacity duration-300 opacity-50': isLoading }">
|
||||||
<div class="flex justify-end mb-5">
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<DataTable :columns="columns" :data="roles" />
|
<DataTable :columns="columns" :data="roles" />
|
||||||
@@ -16,14 +18,12 @@ import { columns } from '@/features/admin/roles/dataTableColumns.js'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import DataTable from '@/components/datatable/DataTable.vue'
|
import DataTable from '@/components/datatable/DataTable.vue'
|
||||||
import { handleHTTPError } from '@/utils/http'
|
import { handleHTTPError } from '@/utils/http'
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { Spinner } from '@/components/ui/spinner'
|
import { Spinner } from '@/components/ui/spinner'
|
||||||
import { useEmitter } from '@/composables/useEmitter'
|
import { useEmitter } from '@/composables/useEmitter'
|
||||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
|
|
||||||
const emitter = useEmitter()
|
const emitter = useEmitter()
|
||||||
const router = useRouter()
|
|
||||||
const roles = ref([])
|
const roles = ref([])
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
|
|
||||||
@@ -49,8 +49,4 @@ onMounted(async () => {
|
|||||||
if (data?.model === 'team') getRoles()
|
if (data?.model === 'team') getRoles()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const navigateToNewRole = () => {
|
|
||||||
router.push('/admin/teams/roles/new')
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #help>
|
<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>
|
</template>
|
||||||
</AdminPageWithHelp>
|
</AdminPageWithHelp>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user