Compare commits

...

57 Commits

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

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

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

31
.github/workflows/github-pages.yml vendored Normal file
View File

@@ -0,0 +1,31 @@
name: Deploy MkDocs
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: 3.x
- run: pip install mkdocs-material
- run: |
if [ -f requirements.txt ]; then
pip install -r requirements.txt;
fi
- run: cd docs && mkdocs build
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./docs/site

62
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,62 @@
name: Release
on:
push:
tags:
- "v*"
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: docker-container
cache-to: type=gha
cache-from: type=gha
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.21"
cache: true
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: '18.12'
- name: Install pnpm
run: npm install -g pnpm
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v5
with:
version: latest
args: release --parallelism 1 --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DOCKER_ORG: libredesk
GITHUB_ORG: ${{ github.repository_owner }}

9
.gitignore vendored
View File

@@ -1,5 +1,10 @@
node_modules
config.toml
config.toml.*
libredesk.bin
uploads/*
.env
libredesk
libredesk.exe
uploads
.env
dist/
.vscode/

198
.goreleaser.yaml Normal file
View File

@@ -0,0 +1,198 @@
env:
- GO111MODULE=on
- CGO_ENABLED=0
- GITHUB_ORG=abhinavxd
- DOCKER_ORG=libredesk
before:
hooks:
- go mod tidy
- make frontend-build
builds:
- id: "standard"
main: ./cmd
env:
- CGO_ENABLED=0
goos:
- darwin
- freebsd
- linux
- netbsd
- openbsd
- windows
goarch:
- amd64
- arm64
binary: 'libredesk{{ if eq .Os "windows" }}.exe{{ end }}'
ldflags:
- -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
hooks:
post: make stuff BIN={{ .Path }}
- id: "arm"
main: ./cmd
env:
- CGO_ENABLED=0
goos:
- freebsd
- linux
- netbsd
- openbsd
goarch:
- arm
goarm:
- 6
- 7
binary: 'libredesk{{ if eq .Os "windows" }}.exe{{ end }}'
ldflags:
- -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
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:
- standard
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:
- standard
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:
- arm
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:
- arm
image_templates:
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-armv7"
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv7"
- "ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:latest-armv7"
- "ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv7"
build_flag_templates:
- --platform=linux/arm/v7
- --label=org.opencontainers.image.title={{ .ProjectName }}
- --label=org.opencontainers.image.description={{ .ProjectName }}
- --label=org.opencontainers.image.url=https://github.com/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}
- --label=org.opencontainers.image.source=https://github.com/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}
- --label=org.opencontainers.image.version={{ .Version }}
- --label=org.opencontainers.image.created={{ time "2006-01-02T15:04:05Z07:00" }}
- --label=org.opencontainers.image.revision={{ .FullCommit }}
- --label=org.opencontainers.image.licenses=AGPL-3.0
dockerfile: Dockerfile
extra_files:
- config.sample.toml
docker_manifests:
- name_template: "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest"
image_templates:
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-amd64"
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-arm64"
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-armv6"
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-armv7"
- name_template: "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}"
image_templates:
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-amd64"
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-arm64"
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv6"
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv7"
- name_template: ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:latest
image_templates:
- ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:latest-amd64
- ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:latest-arm64
- ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:latest-armv6
- ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:latest-armv7
- name_template: ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:{{ .Tag }}
image_templates:
- ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:{{ .Tag }}-amd64
- ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:{{ .Tag }}-arm64
- ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv6
- ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv7
release:
github:
owner: abhinavxd
name: libredesk
prerelease: auto
draft: true

18
Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
# Use the latest version of Alpine Linux as the base image
FROM alpine:latest
# Install necessary packages
RUN apk --no-cache add ca-certificates
# Set the working directory to /libredesk
WORKDIR /libredesk
# Copy necessary files
COPY libredesk .
COPY config.sample.toml config.toml
# Expose port 9000 for the application
EXPOSE 9000
# Set the default command to run the libredesk binary
CMD ["./libredesk"]

View File

@@ -5,7 +5,7 @@ VERSION := $(shell git describe --tags)
BUILDSTR := ${VERSION} (Commit: ${LAST_COMMIT_DATE} (${LAST_COMMIT}), Build: $(shell date +"%Y-%m-%d %H:%M:%S %z"))
# Binary names and paths
BIN_LIBREDESK := libredesk.bin
BIN := libredesk
FRONTEND_DIR := frontend
FRONTEND_DIST := ${FRONTEND_DIR}/dist
STATIC := ${FRONTEND_DIST} i18n schema.sql static
@@ -28,7 +28,7 @@ install-deps: $(STUFFBIN)
# Build the frontend for production.
.PHONY: frontend-build
frontend-build:
frontend-build: install-deps
@echo "→ Building frontend for production..."
@cd ${FRONTEND_DIR} && pnpm build
@@ -36,7 +36,7 @@ frontend-build:
.PHONY: run-backend
run-backend:
@echo "→ Running backend..."
@go run cmd/*.go
CGO_ENABLED=0 go run -ldflags="-s -w -X 'main.buildString=${BUILDSTR}' -X 'main.frontendDir=frontend/dist'" cmd/*.go
# Run the JS frontend server in development mode.
.PHONY: run-frontend
@@ -51,8 +51,8 @@ run-frontend:
backend-build: $(STUFFBIN)
@echo "→ Building backend..."
@CGO_ENABLED=0 go build -a\
-ldflags="-X 'main.buildString=${BUILDSTR}' -X 'main.buildDate=${LAST_COMMIT_DATE}' -s -w" \
-o ${BIN_LIBREDESK} cmd/*.go
-ldflags="-X 'main.buildString=${BUILDSTR}' -s -w" \
-o ${BIN} cmd/*.go
# Main build target: builds both frontend and backend, then stuffs static assets into the binary.
.PHONY: build
@@ -63,7 +63,7 @@ build: frontend-build backend-build stuff
.PHONY: stuff
stuff: $(STUFFBIN)
@echo "→ Stuffing static assets into binary..."
@$(STUFFBIN) -a stuff -in ${BIN_LIBREDESK} -out ${BIN_LIBREDESK} ${STATIC}
@$(STUFFBIN) -a stuff -in ${BIN} -out ${BIN} ${STATIC}
# Build the application in demo mode.
.PHONY: demo-build

View File

@@ -1,39 +1,81 @@
<a href="https://zerodha.tech"><img src="https://zerodha.tech/static/images/github-badge.svg" align="right" alt="Zerodha Tech Badge" /></a>
# Libredesk
Open-source, self-hosted customer support desk. Single binary app.
Open source, self-hosted customer support desk. Single binary app.
> This project is currently in **alpha**. Features and APIs may change and are not yet fully tested.
Visit [libredesk.io](https://libredesk.io) for more info. Check out the [**Live demo**](https://demo.libredesk.io/).
## Developer Setup
![Screenshot_20250220_231723](https://github.com/user-attachments/assets/55e0ec68-b624-4442-8387-6157742da253)
#### Prerequisites
- **go**
- **pnpm**
- **PostgreSQL >= 13**
- **Redis**
> **CAUTION:** This project is currently in **alpha**. Features and APIs may change and are not yet fully tested.
1. **Clone the repository**:
## Features
```bash
git clone https://github.com/abhinavxd/libredesk.git
cd libredesk
```
- **Multi Inbox**
Libredesk supports multiple inboxes, letting you manage conversations across teams effortlessly.
- **Granular Permissions**
Create custom roles with granular permissions for teams and individual agents.
- **Smart Automation**
Eliminate repetitive tasks with powerful automation rules. Auto-tag, assign, and route conversations based on custom conditions.
- **CSAT Surveys**
Measure customer satisfaction with automated surveys.
- **Macros**
Save frequently sent messages as templates. With one click, send saved responses, set tags, and more.
- **Smart Organization**
Keep conversations organized with tags, custom statuses for conversations, and snoozing. Find any conversation instantly from the search bar.
- **Auto Assignment**
Distribute workload with auto assignment rules. Auto-assign conversations based on agent capacity or custom criteria.
- **SLA Management**
Set and track response time targets. Get notified when conversations are at risk of breaching SLA commitments.
- **Business Intelligence**
Connect your favorite BI tools like Metabase and create custom dashboards and reports with your support data—without lock-ins.
- **AI-Assisted Response Rewrite**
Instantly rewrite responses with AI to make them more friendly, professional, or polished.
- **Command Bar**
Opens with a simple shortcut (CTRL+k) and lets you quickly perform actions on conversations.
2. **Configure the Application**:
And more checkout - [libredesk.io](https://libredesk.io)
- Copy the sample configuration file `config.toml.sample` to `config.toml`:
```bash
cp config.toml.sample config.toml
```
- Edit the `config.toml` file to configure your database and Redis connection settings.
3. **Run in Development Mode**:
## Installation
- Backend: `make run-backend`
- Frontend: `make run-frontend`
### Docker
---
The latest image is available on DockerHub at [`libredesk/libredesk:latest`](https://hub.docker.com/r/libredesk/libredesk/tags?page=1&ordering=last_updated&name=latest)
Visit [libredesk.io](https://libredesk.io) for more info.
```shell
# Download the compose file to the current directory.
curl -LO https://github.com/abhinavxd/libredesk/raw/master/docker-compose.yml
# 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.

View File

@@ -37,8 +37,9 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
// OpenID connect single sign-on.
g.GET("/api/v1/oidc/enabled", handleGetAllEnabledOIDC)
g.GET("/api/v1/oidc", perm(handleGetAllOIDC, "oidc:manage"))
g.GET("/api/v1/oidc/{id}", perm(handleGetOIDC, "oidc:manage"))
g.POST("/api/v1/oidc", perm(handleCreateOIDC, "oidc:manage"))
g.POST("/api/v1/oidc/test", perm(handleTestOIDC, "oidc:manage"))
g.GET("/api/v1/oidc/{id}", perm(handleGetOIDC, "oidc:manage"))
g.PUT("/api/v1/oidc/{id}", perm(handleUpdateOIDC, "oidc:manage"))
g.DELETE("/api/v1/oidc/{id}", perm(handleDeleteOIDC, "oidc:manage"))

View File

@@ -98,6 +98,9 @@ func initFlags() {
"path to one or more config files (will be merged in order)")
f.Bool("version", false, "show current version of the build")
f.Bool("install", false, "setup database")
f.Bool("idempotent-install", false, "run idempotent installation, i.e., skip installion if schema is already installed useful for the first time setup")
f.Bool("yes", false, "skip confirmation prompt")
f.Bool("upgrade", false, "upgrade the database schema")
f.Bool("set-system-user-password", false, "set password for the system user")
if err := f.Parse(os.Args[1:]); err != nil {

View File

@@ -4,23 +4,38 @@ import (
"context"
"fmt"
"log"
"os"
"strings"
"time"
"github.com/abhinavxd/libredesk/internal/colorlog"
"github.com/abhinavxd/libredesk/internal/user"
"github.com/jmoiron/sqlx"
"github.com/knadh/stuffbin"
"github.com/lib/pq"
)
// install checks if the schema is already installed, prompts for confirmation, and installs the schema if needed.
func install(ctx context.Context, db *sqlx.DB, fs stuffbin.FileSystem) error {
installed, err := checkSchema(db)
// Install checks if the schema is already installed, prompts for confirmation, and installs the schema if needed.
// idempotent install skips the installation if the database schema is already installed.
func install(ctx context.Context, db *sqlx.DB, fs stuffbin.FileSystem, idempotentInstall, prompt bool) error {
schemaInstalled, err := checkSchema(db)
if err != nil {
log.Fatalf("error checking db schema: %v", err)
log.Fatalf("error checking existing db schema: %v", err)
}
if installed {
fmt.Printf("\033[31m** WARNING: This will wipe your entire database - '%s' **\033[0m\n", ko.String("db.database"))
fmt.Print("Continue (y/n)? ")
// Make sure the system user password is strong enough.
password := strings.TrimSpace(os.Getenv("LIBREDESK_SYSTEM_USER_PASSWORD"))
if password != "" && !user.IsStrongSystemUserPassword(password) && !schemaInstalled {
log.Fatalf("system user password is not strong, %s", user.SystemUserPasswordHint)
}
if !idempotentInstall {
log.Println("running first time setup...")
colorlog.Red(fmt.Sprintf("WARNING: This will wipe your entire database - '%s'", ko.String("db.database")))
}
if prompt {
log.Print("Continue (y/n)? ")
var ok string
fmt.Scanf("%s", &ok)
if !strings.EqualFold(ok, "y") {
@@ -28,15 +43,26 @@ func install(ctx context.Context, db *sqlx.DB, fs stuffbin.FileSystem) error {
}
}
if idempotentInstall {
if schemaInstalled {
log.Println("skipping installation as schema is already installed")
os.Exit(0)
}
} else {
time.Sleep(5 * time.Second)
}
log.Println("installing database schema...")
// Install schema.
if err := installSchema(db, fs); err != nil {
log.Fatalf("error installing schema: %v", err)
}
log.Println("Schema installed successfully")
log.Println("database schema installed successfully")
// Create system user.
if err := user.CreateSystemUser(ctx, db); err != nil {
if err := user.CreateSystemUser(ctx, password, db); err != nil {
log.Fatalf("error creating system user: %v", err)
}
return nil

View File

@@ -114,7 +114,7 @@ func main() {
// Installer.
if ko.Bool("install") {
install(ctx, db, fs)
install(ctx, db, fs, ko.Bool("idempotent-install"), !ko.Bool("yes"))
os.Exit(0)
}
@@ -130,7 +130,13 @@ func main() {
log.Fatalf("error checking db schema: %v", err)
}
if !installed {
log.Println("Database tables are missing. Use the `--install` flag to set up the database schema.")
log.Println("database tables are missing. Use the `--install` flag to set up the database schema.")
os.Exit(0)
}
// Upgrade.
if ko.Bool("upgrade") {
log.Println("no upgrades available")
os.Exit(0)
}

View File

@@ -44,6 +44,19 @@ func handleGetOIDC(r *fastglue.Request) error {
return r.SendEnvelope(o)
}
// handleTestOIDC tests an OIDC provider URL by doing a discovery on the provider URL.
func handleTestOIDC(r *fastglue.Request) error {
var (
app = r.Context.(*App)
providerURL = string(r.RequestCtx.PostArgs().Peek("provider_url"))
)
if err := app.auth.TestProvider(providerURL); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("OIDC provider discovered successfully")
}
// handleCreateOIDC creates a new OIDC record.
func handleCreateOIDC(r *fastglue.Request) error {
var (
app = r.Context.(*App)
@@ -52,18 +65,19 @@ func handleCreateOIDC(r *fastglue.Request) error {
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Bad request", nil, envelope.GeneralError)
}
err := app.oidc.Create(req)
if err != nil {
if err := app.oidc.Create(req); err != nil {
return sendErrorEnvelope(r, err)
}
// Reload the auth manager to update the OIDC providers.
if err := reloadAuth(app); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error reloading auth", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, envelope.GeneralError)
}
return r.SendEnvelope("OIDC created successfully")
}
// handleUpdateOIDC updates an OIDC record.
func handleUpdateOIDC(r *fastglue.Request) error {
var (
app = r.Context.(*App)
@@ -79,8 +93,7 @@ func handleUpdateOIDC(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Bad request", nil, envelope.GeneralError)
}
err = app.oidc.Update(id, req)
if err != nil {
if err = app.oidc.Update(id, req); err != nil {
return sendErrorEnvelope(r, err)
}
@@ -91,23 +104,16 @@ func handleUpdateOIDC(r *fastglue.Request) error {
return r.SendEnvelope("OIDC updated successfully")
}
// handleDeleteOIDC deletes an OIDC record.
func handleDeleteOIDC(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
var app = r.Context.(*App)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid oidc `id`.", nil, envelope.InputError)
}
err = app.oidc.Delete(id)
if err != nil {
if err = app.oidc.Delete(id); err != nil {
return sendErrorEnvelope(r, err)
}
// Reload the auth manager to update the OIDC providers.
if err := reloadAuth(app); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, envelope.GeneralError)
}
return r.SendEnvelope("OIDC deleted successfully")
}

View File

@@ -9,6 +9,7 @@ import (
"github.com/zerodha/fastglue"
)
// handleGetRoles returns all roles
func handleGetRoles(r *fastglue.Request) error {
var (
app = r.Context.(*App)
@@ -20,6 +21,7 @@ func handleGetRoles(r *fastglue.Request) error {
return r.SendEnvelope(agents)
}
// handleGetRole returns a single role
func handleGetRole(r *fastglue.Request) error {
var (
app = r.Context.(*App)
@@ -32,18 +34,19 @@ func handleGetRole(r *fastglue.Request) error {
return r.SendEnvelope(role)
}
// handleDeleteRole deletes a role
func handleDeleteRole(r *fastglue.Request) error {
var (
app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
err := app.role.Delete(id)
if err != nil {
if err := app.role.Delete(id); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
return r.SendEnvelope("Role deleted successfully")
}
// handleCreateRole creates a new role
func handleCreateRole(r *fastglue.Request) error {
var (
app = r.Context.(*App)
@@ -52,13 +55,13 @@ func handleCreateRole(r *fastglue.Request) error {
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
}
err := app.role.Create(req)
if err != nil {
if err := app.role.Create(req); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
return r.SendEnvelope("Role created successfully")
}
// handleUpdateRole updates a role
func handleUpdateRole(r *fastglue.Request) error {
var (
app = r.Context.(*App)
@@ -68,9 +71,8 @@ func handleUpdateRole(r *fastglue.Request) error {
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
}
err := app.role.Update(id, req)
if err != nil {
if err := app.role.Update(id, req);err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
return r.SendEnvelope("Role updated successfully")
}

View File

@@ -9,16 +9,16 @@ address = "0.0.0.0:9000"
socket = ""
read_timeout = "5s"
write_timeout = "5s"
max_body_size = 10000000
max_body_size = 500000000
keepalive_timeout = "10s"
# File upload provider.
# File upload provider to use, either `fs` or `s3`.
[upload]
provider = "fs"
# Filesytem provider.
[upload.fs]
upload_path = '/home/ubuntu/uploads'
upload_path = 'uploads'
# S3 provider.
[upload.s3]
@@ -32,6 +32,7 @@ expiry = "6h"
# Postgres.
[db]
# If using docker compose, use the service name as the host. e.g. db
host = "127.0.0.1"
port = 5432
user = "postgres"
@@ -44,6 +45,7 @@ max_lifetime = "300s"
# Redis.
[redis]
# If using docker compose, use the service name as the host. e.g. redis
address = "127.0.0.1:6379"
password = ""
db = 0

62
docker-compose.yml Normal file
View File

@@ -0,0 +1,62 @@
services:
# Libredesk app
app:
image: libredesk:latest
container_name: libredesk_app
restart: unless-stopped
ports:
- "9000:9000"
environment:
# If the password is set during first docker-compose up, the system user password will be set to this value.
# You can always set system user password later by running `docker exec -it libredesk_app ./libredesk --set-system-user-password`.
LIBREDESK_SYSTEM_USER_PASSWORD: ${LIBREDESK_SYSTEM_USER_PASSWORD:-}
networks:
- libredesk
depends_on:
- db
- redis
volumes:
- ./uploads:/libredesk/uploads:rw
- ./config.toml:/libredesk/config.toml
command: [sh, -c, "./libredesk --install --idempotent-install --yes --config /libredesk/config.toml && ./libredesk --upgrade --yes --config /libredesk/config.toml && ./libredesk --config /libredesk/config.toml"]
# PostgreSQL database
db:
image: postgres:17-alpine
container_name: libredesk_db
restart: unless-stopped
networks:
- libredesk
ports:
- "5432:5432"
environment:
# Set these environment variables to configure the database, defaults to libredesk.
POSTGRES_USER: ${POSTGRES_USER:-libredesk}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-libredesk}
POSTGRES_DB: ${POSTGRES_DB:-libredesk}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U libredesk"]
interval: 10s
timeout: 5s
retries: 6
volumes:
- postgres-data:/var/lib/postgresql/data
# Redis
redis:
image: redis:7-alpine
container_name: libredesk_redis
restart: unless-stopped
ports:
- "6379:6379"
networks:
- libredesk
volumes:
- redis-data:/data
networks:
libredesk:
volumes:
postgres-data:
redis-data:

View File

@@ -0,0 +1,31 @@
# Developer Setup
Libredesk is a monorepo with a Go backend and a Vue.js frontend. The frontend uses Shadcn for UI components.
### Pre-requisites
- `go`
- `nodejs` (if you are working on the frontend) and `pnpm`
- Postgres database (>= 13)
### First time setup
Clone the repository:
```sh
git clone https://github.com/abhinavxd/libredesk.git
```
1. Copy `config.toml.sample` as `config.toml` and add your config.
2. Run `make` to build the libredesk binary. Once the binary is built, run `./libredesk --install` to run the DB setup and set the System user password.
### Running the Dev Environment
1. Run `make run` to start the libredesk backend dev server on `:9000`.
2. Run `make run-frontend` to start the Vue frontend in dev mode using pnpm on `:8000`. Requests are proxied to the backend running on `:9000` check `vite.config.js` for the proxy config.
---
# Production Build
Run `make` to build the Go binary, build the Javascript frontend, and embed the static assets producing a single self-contained binary, `libredesk`.

13
docs/docs/index.md Normal file
View File

@@ -0,0 +1,13 @@
# Introduction
Libredesk is an open source, self-hosted customer support desk. Single binary app.
<div style="border: 1px solid #ccc; padding: 1px; border-radius:5px; box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.1); background-color: #fff;">
<a href="https://libredesk.io">
<img src="https://hebbkx1anhila5yf.public.blob.vercel-storage.com/Screenshot_20250220_231723-VxuEQgEiFfI9xhzJDOvgMK0yJ0TwR3.png" alt="libredesk screenshot" style="display: block; margin: 0 auto;">
</a>
</div>
## Developers
Libredesk is a free and open source software licensed under AGPLv3. If you are interested in contributing, check out the [GitHub repository](https://github.com/abhinavxd/libredesk) and refer to the [developer setup](developer-setup.md). The backend is written in Go and the frontend is Vue js 3 with Shadcn for UI components.

47
docs/docs/installation.md Normal file
View File

@@ -0,0 +1,47 @@
# 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/llibredeskistmonk:latest`
The recommended method is to download the [docker-compose.yml](https://github.com/abhinavxd/libredesk/blob/master/docker-compose.yml) file, customize it for your environment and then to simply run `docker compose up -d`.
```shell
# Download the compose file to the current directory.
curl -LO https://github.com/abhinavxd/libredesk/raw/master/docker-compose.yml
# 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 (`master` branch):
1. Make sure `go`, `nodejs`, and `pnpm` are installed on your system.
2. `git clone git@github.com:abhinavxd/libredesk.git`
3. `cd libredesk && make`. This will generate the `libredesk` binary.

18
docs/docs/upgrade.md Normal file
View File

@@ -0,0 +1,18 @@
# Upgrade
!!! Warning
Always take a backup of the Postgres database before upgrading Libredesk.
## Binary
- Stop running libredesk binary.
- Download the [latest release](https://github.com/abhinavxd/libredesk/releases) and extract the libredesk binary and overwrite the previous version.
- `./libredesk --upgrade` to upgrade an existing database schema. Upgrades are idempotent and running them multiple times have no side effects.
- Run `./libredesk` again.
## Docker
```shell
docker compose down app
docker compose pull
docker compose up app -d
```

34
docs/mkdocs.yml Normal file
View File

@@ -0,0 +1,34 @@
site_name: Libredesk Documentation
theme:
name: material
language: en
font:
text: Source Sans Pro
code: Roboto Mono
weights:
- 400
- 700
direction: ltr
palette:
primary: white
accent: red
features:
- navigation.indexes
- navigation.sections
- content.code.copy
extra:
search:
language: en
markdown_extensions:
- admonition
- codehilite
- toc:
permalink: true
nav:
- Introduction: index.md
- Getting Started:
- Installation: installation.md
- Upgrade: upgrade.md
- Developer Setup: developer-setup.md

View File

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

View File

@@ -7,7 +7,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap"
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&display=swap"
rel="stylesheet">
</head>

View File

@@ -18,41 +18,29 @@
"@formkit/auto-animate": "^0.8.2",
"@internationalized/date": "^3.5.5",
"@radix-icons/vue": "^1.0.0",
"@tailwindcss/typography": "^0.5.10",
"@tanstack/vue-table": "^8.19.2",
"@tiptap/extension-image": "^2.5.9",
"@tiptap/extension-link": "^2.9.1",
"@tiptap/extension-ordered-list": "^2.4.0",
"@tiptap/extension-placeholder": "^2.4.0",
"@tiptap/pm": "^2.4.0",
"@tiptap/starter-kit": "^2.4.0",
"@tiptap/suggestion": "^2.4.0",
"@tiptap/vue-3": "^2.4.0",
"@unovis/ts": "^1.4.4",
"@unovis/vue": "^1.4.4",
"@vee-validate/zod": "^4.13.2",
"@vue/reactivity": "^3.4.15",
"@vue/runtime-core": "^3.4.15",
"@vueup/vue-quill": "^1.2.0",
"@vueuse/core": "^12.4.0",
"add": "^2.0.6",
"axios": "^1.7.9",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"codeflask": "^1.4.1",
"date-fns": "^3.6.0",
"install": "^0.13.0",
"lucide-vue-next": "^0.378.0",
"mitt": "^3.0.1",
"npm": "^10.4.0",
"npx": "^10.2.2",
"pinia": "^2.1.7",
"qs": "^6.12.1",
"radix-vue": "latest",
"shadcn-vue": "latest",
"tailwind-merge": "^2.3.0",
"tailwindcss-animate": "^1.0.7",
"textarea": "^0.3.0",
"vee-validate": "^4.13.2",
"vue": "^3.4.37",
"vue-i18n": "9",
@@ -68,7 +56,7 @@
"@rushstack/eslint-patch": "^1.3.3",
"@vitejs/plugin-vue": "^5.0.3",
"@vue/eslint-config-prettier": "^8.0.0",
"autoprefixer": "latest",
"autoprefixer": "^10.4.20",
"cypress": "^13.6.3",
"eslint": "^8.49.0",
"eslint-plugin-cypress": "^2.15.1",
@@ -78,6 +66,7 @@
"sass": "^1.70.0",
"start-server-and-test": "^2.0.3",
"tailwindcss": "latest",
"tailwindcss-animate": "^1.0.7",
"vite": "^5.4.9"
},
"packageManager": "pnpm@9.15.3+sha512.1f79bc245a66eb0b07c5d4d83131240774642caaa86ef7d0434ab47c0d16f66b04e21e0c086eb61e62c77efc4d7f7ec071afad3796af64892fae66509173893a"

1640
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -47,8 +47,10 @@
@edit-view="editView"
@delete-view="deleteView"
>
<PageHeader />
<RouterView />
<div class="flex flex-col h-screen">
<PageHeader />
<RouterView class="flex-grow" />
</div>
<ViewForm v-model:openDialog="openCreateViewForm" v-model:view="view" />
</Sidebar>
</div>

View File

@@ -90,6 +90,7 @@ const createOIDC = (data) =>
'Content-Type': 'application/json'
}
})
const testOIDC = (data) => http.post('/api/v1/oidc/test', data)
const getAllEnabledOIDC = () => http.get('/api/v1/oidc/enabled')
const getAllOIDC = () => http.get('/api/v1/oidc')
const getOIDC = (id) => http.get(`/api/v1/oidc/${id}`)
@@ -344,6 +345,7 @@ export default {
getAllEnabledOIDC,
getOIDC,
updateOIDC,
testOIDC,
deleteOIDC,
getTemplate,
getTemplates,

View File

@@ -6,13 +6,6 @@
font-size: 16px;
}
.page-content {
height: 100vh;
overflow-y: scroll;
padding-bottom: 100px;
background-color: #fff;
}
@layer base {
html,
body {
@@ -32,66 +25,65 @@
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border:240 5.9% 90%;
--input:240 5.9% 90%;
--ring:240 5.9% 10%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 5.9% 10%;
--radius: 0.75rem;
}
.dark {
--background:240 10% 3.9%;
--foreground:0 0% 98%;
--card:240 10% 3.9%;
--card-foreground:0 0% 98%;
--popover:240 10% 3.9%;
--popover-foreground:0 0% 98%;
--primary:0 0% 98%;
--primary-foreground:240 5.9% 10%;
--secondary:240 3.7% 15.9%;
--secondary-foreground:0 0% 98%;
--muted:240 3.7% 15.9%;
--muted-foreground:240 5% 64.9%;
--accent:240 3.7% 15.9%;
--accent-foreground:0 0% 98%;
--destructive:0 62.8% 30.6%;
--destructive-foreground:0 0% 98%;
--border:240 3.7% 15.9%;
--input:240 3.7% 15.9%;
--ring:240 4.9% 83.9%;
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
}
}
@layer base {
:root {
--vis-tooltip-background-color: none !important;
@@ -239,7 +231,7 @@
// Sidebar start
@layer base {
:root {
--sidebar-background: 0 0% 97%;
--sidebar-background: 0 0% 96%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
@@ -320,3 +312,7 @@ a[data-active='false']:hover {
opacity: 1;
}
}
[data-radix-popper-content-wrapper] {
z-index: 9999 !important;
}

View File

@@ -1,8 +1,8 @@
<template>
<div v-if="!isHidden">
<div class="flex items-center space-x-4 p-4">
<div class="flex items-center space-x-4 h-12 px-2">
<SidebarTrigger class="cursor-pointer w-4 h-4" />
<span class="text-2xl font-semibold">
<span class="text-xl font-semibold text-gray-800">
{{ title }}
</span>
</div>

View File

@@ -2,9 +2,10 @@
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem v-for="(item, index) in links" :key="index">
<router-link :to="item.path">
<router-link :to="{ name: item.path }" v-if="item.path">
{{ item.label }}
</router-link>
<span v-else>{{ item.label }}</span>
<BreadcrumbSeparator v-if="index < links.length - 1">
<ChevronRight />
</BreadcrumbSeparator>

View File

@@ -3,6 +3,7 @@ import { Primitive } from 'radix-vue'
import { buttonVariants } from '.'
import { cn } from '@/lib/utils'
import { ref, computed } from 'vue'
import { DotLoader } from '@/components/ui/loader'
const props = defineProps({
variant: { type: null, required: false },
@@ -29,11 +30,7 @@ const computedClass = computed(() => {
:class="computedClass"
:disabled="isLoading || isDisabled"
>
<span v-if="isLoading" class="dot-loader">
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
</span>
<DotLoader v-if="isLoading" />
<slot v-else />
</Primitive>
</template>

View File

@@ -1,94 +0,0 @@
<script setup>
import { VisDonut, VisSingleContainer } from '@unovis/vue'
import { Donut } from '@unovis/ts'
import { computed, ref } from 'vue'
import { useMounted } from '@vueuse/core'
import { ChartSingleTooltip, defaultColors } from '@/components/ui/chart'
import { cn } from '@/lib/utils'
const props = defineProps({
data: { type: Array, required: true },
colors: { type: Array, required: false },
index: { type: null, required: true },
margin: {
type: null,
required: false,
default: () => ({ top: 0, bottom: 0, left: 0, right: 0 })
},
showLegend: { type: Boolean, required: false, default: true },
showTooltip: { type: Boolean, required: false, default: true },
filterOpacity: { type: Number, required: false, default: 0.2 },
category: { type: String, required: true },
type: { type: String, required: false, default: 'donut' },
sortFunction: { type: Function, required: false, default: () => undefined },
valueFormatter: { type: Function, required: false, default: (tick) => `${tick}` },
customTooltip: { type: null, required: false }
})
const category = computed(() => props.category)
const index = computed(() => props.index)
const isMounted = useMounted()
const activeSegmentKey = ref()
const colors = computed(() =>
props.colors?.length
? props.colors
: defaultColors(props.data.filter((d) => d[props.category]).filter(Boolean).length)
)
const legendItems = computed(() =>
props.data.map((item, i) => ({
name: item[props.index],
color: colors.value[i],
inactive: false
}))
)
const totalValue = computed(() =>
props.data.reduce((prev, curr) => {
return prev + curr[props.category]
}, 0)
)
</script>
<template>
<div :class="cn('w-full h-48 flex flex-col items-end', $attrs.class ?? '')">
<VisSingleContainer
:style="{ height: isMounted ? '100%' : 'auto' }"
:margin="{ left: 20, right: 20 }"
:data="data"
>
<ChartSingleTooltip
:selector="Donut.selectors.segment"
:index="category"
:items="legendItems"
:value-formatter="valueFormatter"
:custom-tooltip="customTooltip"
/>
<VisDonut
:value="(d) => d[category]"
:sort-function="sortFunction"
:color="colors"
:arc-width="type === 'donut' ? 20 : 0"
:show-background="false"
:central-label="type === 'donut' ? valueFormatter(totalValue) : ''"
:events="{
[Donut.selectors.segment]: {
click: (d, ev, i, elements) => {
if (d?.data?.[index] === activeSegmentKey) {
activeSegmentKey = undefined
elements.forEach((el) => (el.style.opacity = '1'))
} else {
activeSegmentKey = d?.data?.[index]
elements.forEach((el) => (el.style.opacity = `${filterOpacity}`))
elements[i].style.opacity = '1'
}
}
}
}"
/>
<slot />
</VisSingleContainer>
</div>
</template>

View File

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

View File

@@ -1,9 +1,7 @@
<template>
<div class="flex flex-col items-center justify-center text-gray-600 dark:text-gray-300">
<span class="dot-loader">
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
</span>
</div>
<span class="dot-loader">
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
</span>
</template>

View File

@@ -1,43 +0,0 @@
<script setup>
import { computed } from 'vue'
import { SplitterResizeHandle, useForwardPropsEmits } from 'radix-vue'
import { DragHandleDots2Icon } from '@radix-icons/vue'
import { cn } from '@/lib/utils'
const props = defineProps({
id: { type: String, required: false },
hitAreaMargins: { type: Object, required: false },
tabindex: { type: Number, required: false },
disabled: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
withHandle: { type: Boolean, required: false }
})
const emits = defineEmits(['dragging'])
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<SplitterResizeHandle
v-bind="forwarded"
:class="
cn(
'relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 [&[data-orientation=vertical]]:h-px [&[data-orientation=vertical]]:w-full [&[data-orientation=vertical]]:after:left-0 [&[data-orientation=vertical]]:after:h-1 [&[data-orientation=vertical]]:after:w-full [&[data-orientation=vertical]]:after:-translate-y-1/2 [&[data-orientation=vertical]]:after:translate-x-0 [&[data-orientation=vertical]>div]:rotate-90',
props.class
)
"
>
<template v-if="props.withHandle">
<div class="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<DragHandleDots2Icon class="h-2.5 w-2.5" />
</div>
</template>
</SplitterResizeHandle>
</template>

View File

@@ -1,33 +0,0 @@
<script setup>
import { computed } from 'vue'
import { SplitterGroup, useForwardPropsEmits } from 'radix-vue'
import { cn } from '@/lib/utils'
const props = defineProps({
id: { type: [String, null], required: false },
autoSaveId: { type: [String, null], required: false },
direction: { type: String, required: true },
keyboardResizeBy: { type: [Number, null], required: false },
storage: { type: Object, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false }
})
const emits = defineEmits(['layout'])
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<SplitterGroup
v-bind="forwarded"
:class="cn('flex h-full w-full data-[panel-group-direction=vertical]:flex-col', props.class)"
>
<slot />
</SplitterGroup>
</template>

View File

@@ -1,3 +0,0 @@
export { default as ResizablePanelGroup } from './ResizablePanelGroup.vue'
export { default as ResizableHandle } from './ResizableHandle.vue'
export { SplitterPanel as ResizablePanel } from 'radix-vue'

View File

@@ -1,31 +0,0 @@
<script setup>
import { computed } from 'vue'
import { ScrollAreaCorner, ScrollAreaRoot, ScrollAreaViewport } from 'radix-vue'
import ScrollBar from './ScrollBar.vue'
import { cn } from '@/lib/utils'
const props = defineProps({
type: { type: String, required: false },
dir: { type: String, required: false },
scrollHideDelay: { type: Number, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false }
})
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<ScrollAreaRoot v-bind="delegatedProps" :class="cn('relative overflow-hidden', props.class)">
<ScrollAreaViewport class="h-full w-full rounded-[inherit]">
<slot />
</ScrollAreaViewport>
<ScrollBar />
<ScrollAreaCorner />
</ScrollAreaRoot>
</template>

View File

@@ -1,35 +0,0 @@
<script setup>
import { computed } from 'vue'
import { ScrollAreaScrollbar, ScrollAreaThumb } from 'radix-vue'
import { cn } from '@/lib/utils'
const props = defineProps({
orientation: { type: String, required: false, default: 'vertical' },
forceMount: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false }
})
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<ScrollAreaScrollbar
v-bind="delegatedProps"
:class="
cn(
'flex touch-none select-none transition-colors',
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-px',
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-px',
props.class
)
"
>
<ScrollAreaThumb class="relative flex-1 rounded-full bg-border" />
</ScrollAreaScrollbar>
</template>

View File

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

View File

@@ -1,43 +0,0 @@
<script setup>
import { computed, provide } from 'vue'
import { ToggleGroupRoot, useForwardPropsEmits } from 'radix-vue'
import { cn } from '@/lib/utils'
const props = defineProps({
rovingFocus: { type: Boolean, required: false },
disabled: { type: Boolean, required: false },
orientation: { type: String, required: false },
dir: { type: String, required: false },
loop: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
type: { type: null, required: false },
modelValue: { type: null, required: false },
defaultValue: { type: null, required: false },
class: { type: null, required: false },
variant: { type: null, required: false },
size: { type: null, required: false }
})
const emits = defineEmits(['update:modelValue'])
provide('toggleGroup', {
variant: props.variant,
size: props.size
})
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<ToggleGroupRoot
v-bind="forwarded"
:class="cn('flex items-center justify-center gap-1', props.class)"
>
<slot />
</ToggleGroupRoot>
</template>

View File

@@ -1,44 +0,0 @@
<script setup>
import { computed, inject } from 'vue'
import { ToggleGroupItem, useForwardProps } from 'radix-vue'
import { toggleVariants } from '@/components/ui/toggle'
import { cn } from '@/lib/utils'
const props = defineProps({
value: { type: String, required: true },
defaultValue: { type: Boolean, required: false },
pressed: { type: Boolean, required: false },
disabled: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
variant: { type: null, required: false },
size: { type: null, required: false }
})
const context = inject('toggleGroup')
const delegatedProps = computed(() => {
const { class: _, variant, size, ...delegated } = props
return delegated
})
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<ToggleGroupItem
v-bind="forwardedProps"
:class="
cn(
toggleVariants({
variant: context?.variant || variant,
size: context?.size || size
}),
props.class
)
"
>
<slot />
</ToggleGroupItem>
</template>

View File

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

View File

@@ -8,8 +8,6 @@ export const CONVERSATION_LIST_TYPE = {
export const CONVERSATION_DEFAULT_STATUSES = {
OPEN: 'Open',
IN_PROGRESS: 'In Progress',
WAITING: 'Waiting',
SNOOZED: 'Snoozed',
RESOLVED: 'Resolved',
CLOSED: 'Closed',

View File

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

View File

@@ -120,7 +120,7 @@
</DialogFooter>
</DialogContent>
</Dialog>
<Button type="submit" :disabled="isLoading">{{ submitLabel }}</Button>
<Button type="submit" :disabled="isLoading" :isLoading="isLoading">{{ submitLabel }}</Button>
</form>
</template>

View File

@@ -1,8 +1,5 @@
<template>
<form
@submit="onSubmit"
class="space-y-6 w-full"
>
<form @submit="onSubmit" class="space-y-6 w-full">
<FormField v-slot="{ field }" name="site_name">
<FormItem>
<FormLabel>Site Name</FormLabel>
@@ -126,22 +123,28 @@
</FormItem>
</FormField>
<FormField name="allowed_file_upload_extensions" v-slot="{ componentField }">
<FormItem>
<FormLabel>Allowed file upload extensions</FormLabel>
<FormControl>
<TagsInput v-model="componentField.modelValue">
<TagsInputItem v-for="item in componentField.modelValue" :key="item" :value="item">
<TagsInputItemText />
<TagsInputItemDelete />
</TagsInputItem>
<TagsInputInput placeholder="jpg" />
</TagsInput>
</FormControl>
<FormDescription>Use `*` to allow any file.</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField name="allowed_file_upload_extensions" v-slot="{ componentField, handleChange }">
<FormItem>
<FormLabel>Allowed file upload extensions</FormLabel>
<FormControl>
<TagsInput
:modelValue="componentField.modelValue"
@update:modelValue="handleChange"
>
<TagsInputItem v-for="item in componentField.modelValue" :key="item" :value="item">
<TagsInputItemText />
<TagsInputItemDelete />
</TagsInputItem>
<TagsInputInput placeholder="jpg" />
</TagsInput>
</FormControl>
<FormDescription>Use `*` to allow any file.</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<Button type="submit" :isLoading="formLoading"> {{ submitLabel }} </Button>
</form>
</template>

View File

@@ -35,8 +35,8 @@ export const formSchema = z.object({
.min(1, {
message: 'Max upload file size must be at least 1 MB.'
})
.max(30, {
message: 'Max upload file size cannot exceed 30 MB.'
.max(500, {
message: 'Max upload file size cannot exceed 500 MB.'
}),
allowed_file_upload_extensions: z.array(z.string()).nullable().default([]).optional()
})

View File

@@ -7,7 +7,9 @@
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="edit(props.role.id)">Edit</DropdownMenuItem>
<DropdownMenuItem :as-child="true">
<RouterLink :to="{ name: 'edit-sso', params: { id: props.role.id } }">Edit</RouterLink>
</DropdownMenuItem>
<DropdownMenuItem @click="() => (alertOpen = true)">Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -49,12 +51,10 @@ import {
AlertDialogTitle
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { useRouter } from 'vue-router'
import api from '@/api'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
const router = useRouter()
const emit = useEmitter()
const alertOpen = ref(false)
@@ -68,10 +68,6 @@ const props = defineProps({
}
})
function edit(id) {
router.push({ path: `/admin/oidc/${id}/edit` })
}
async function handleDelete() {
await api.deleteOIDC(props.role.id)
alertOpen.value = false

View File

@@ -1,14 +1,16 @@
<template>
<Dialog v-model:open="dialogOpen">
<DropdownMenu>
<DropdownMenuTrigger
as-child
v-if="!CONVERSATION_DEFAULT_STATUSES_LIST.includes(props.status.name)"
>
<Button variant="ghost" class="w-8 h-8 p-0">
<DropdownMenuTrigger as-child>
<Button
variant="ghost"
class="w-8 h-8 p-0"
v-if="!CONVERSATION_DEFAULT_STATUSES_LIST.includes(props.status.name)"
>
<span class="sr-only">Open menu</span>
<MoreHorizontal class="w-4 h-4" />
</Button>
<div v-else class="w-8 h-8 p-0 invisible"></div>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DialogTrigger as-child>

View File

@@ -1,47 +1,44 @@
<template>
<div class="flex flex-col w-full">
<div class="flex flex-col h-screen">
<!-- Header -->
<div class="p-2 border-b flex items-center justify-between">
<div class="flex items-center space-x-3 pr-5">
{{ conversationStore.currentContactName }}
<div class="h-12 flex-shrink-0 px-2 border-b flex items-center justify-between">
<div>
<span v-if="!conversationStore.conversation.loading">
{{ conversationStore.currentContactName }}
</span>
<Skeleton class="w-[130px] h-6" v-else />
</div>
<div class="flex items-center space-x-2">
<div>
<DropdownMenu>
<DropdownMenuTrigger>
<div
class="flex items-center space-x-1 cursor-pointer bg-primary px-2 py-1 rounded-md text-sm"
>
<span
class="text-secondary font-medium inline-block"
v-if="conversationStore.current?.status"
>
{{ conversationStore.current?.status }}
</span>
<span v-else class="text-secondary font-medium inline-block"> Loading... </span>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
v-for="status in conversationStore.statusOptions"
:key="status.value"
@click="handleUpdateStatus(status.label)"
>
{{ status.label }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div>
<DropdownMenu>
<DropdownMenuTrigger>
<div
class="flex items-center space-x-1 cursor-pointer bg-primary px-2 py-1 rounded-md text-sm"
v-if="!conversationStore.conversation.loading"
>
<span class="text-secondary font-medium inline-block">
{{ conversationStore.current?.status }}
</span>
</div>
<Skeleton class="w-[70px] h-6 rounded-full" v-else />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
v-for="status in conversationStore.statusOptions"
:key="status.value"
@click="handleUpdateStatus(status.label)"
>
{{ status.label }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<!-- Messages & reply box -->
<div>
<div class="flex flex-col h-screen">
<MessageList class="flex-1 overflow-y-auto" />
<div class="sticky bottom-0">
<ReplyBox class="h-max" />
</div>
<div class="flex flex-col flex-grow overflow-hidden">
<MessageList class="flex-1 overflow-y-auto" />
<div class="sticky bottom-0">
<ReplyBox class="h-max" />
</div>
</div>
</div>
@@ -60,6 +57,7 @@ import ReplyBox from './ReplyBox.vue'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { CONVERSATION_DEFAULT_STATUSES } from '@/constants/conversation'
import { useEmitter } from '@/composables/useEmitter'
import { Skeleton } from '@/components/ui/skeleton'
const conversationStore = useConversationStore()
const emitter = useEmitter()

View File

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

View File

@@ -35,7 +35,7 @@
<Smile class="h-4 w-4" />
</Toggle>
</div>
<Button class="h-8 w-6 px-8" @click="handleSend" :disabled="!enableSend">Send</Button>
<Button class="h-8 w-6 px-8" @click="handleSend" :disabled="!enableSend" :isLoading="isSending">Send</Button>
</div>
</template>
@@ -57,6 +57,7 @@ const emit = defineEmits(['toggleBold', 'toggleItalic', 'emojiSelect'])
defineProps({
isBold: Boolean,
isItalic: Boolean,
isSending: Boolean,
enableSend: Boolean,
handleSend: Function,
handleFileUpload: Function,

View File

@@ -1,15 +1,13 @@
<template>
<div class="h-screen flex flex-col">
<!-- Header -->
<header class="border-b">
<div class="flex items-center space-x-4 p-2">
<SidebarTrigger class="h-4 w-4" />
<span class="text-xl font-semibold text-gray-800">{{ title }}</span>
</div>
</header>
<div class="flex items-center space-x-4 px-2 h-12 border-b shrink-0">
<SidebarTrigger class="h-4 w-4" />
<span class="text-xl font-semibold text-gray-800">{{ title }}</span>
</div>
<!-- Filters -->
<div class="bg-white px-2 py-2 flex justify-between items-center">
<div class="bg-white p-2 flex justify-between items-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" class="w-30">
@@ -107,7 +105,7 @@
<!-- Loading Skeleton -->
<div v-if="isLoading" key="loading" class="space-y-4">
<ConversationListItemSkeleton v-for="index in 10" :key="index" />
<ConversationListItemSkeleton v-for="index in 5" :key="index" />
</div>
</TransitionGroup>
@@ -126,7 +124,9 @@
<Loader2 v-if="isLoading" class="mr-2 h-4 w-4 animate-spin" />
{{ isLoading ? 'Loading...' : 'Load more' }}
</Button>
<p v-else class="text-sm text-gray-500">All conversations loaded</p>
<p class="text-sm text-gray-500" v-else-if="conversationStore.conversationsList.length > 10">
All conversations loaded
</p>
</div>
</div>
</div>

View File

@@ -39,10 +39,14 @@
<!-- Message preview and unread count -->
<div class="flex items-start justify-between gap-2">
<p class="text-sm text-gray-600 line-clamp-2 flex-1">
<Reply class="inline-block w-4 h-4 mr-1.5 text-green-600 flex-shrink-0" />
<div class="text-sm text-gray-600 flex items-center gap-1.5 flex-1 break-all">
<Reply
class="text-green-600 flex-shrink-0"
size="15"
v-if="conversation.last_message_sender === 'agent'"
/>
{{ trimmedLastMessage }}
</p>
</div>
<div
v-if="conversation.unread_message_count > 0"
class="flex items-center justify-center w-6 h-6 bg-green-600 text-white text-xs font-medium rounded-full"

View File

@@ -25,12 +25,12 @@
<MessageAttachmentPreview :attachments="nonInlineAttachments" />
<!-- Spinner for Pending Messages -->
<Spinner v-if="message.status === 'pending'" size="w-4 h-4" class="mt-2" />
<Spinner v-if="message.status === 'pending'" size="w-4 h-4" />
<!-- Icons -->
<div class="flex items-center space-x-2 mt-2">
<Lock :size="10" v-if="isPrivateMessage" class="text-muted-foreground" />
<CheckCheck :size="14" v-if="showCheckCheck" class="text-green-500" />
<Check :size="14" v-if="showCheckCheck" class="text-green-500" />
<RotateCcw
size="10"
@click="retryMessage(message)"
@@ -69,7 +69,7 @@
import { computed } from 'vue'
import { format } from 'date-fns'
import { useConversationStore } from '@/stores/conversation'
import { Lock, RotateCcw, CheckCheck } from 'lucide-vue-next'
import { Lock, RotateCcw, Check } from 'lucide-vue-next'
import { revertCIDToImageSrc } from '@/utils/strings'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { Spinner } from '@/components/ui/spinner'

View File

@@ -1,7 +1,7 @@
<template>
<div class="flex flex-col relative h-full">
<div ref="threadEl" class="flex-1 overflow-y-auto" @scroll="handleScroll">
<div class="min-h-full pb-20 px-4">
<div class="min-h-full px-4 pb-10">
<div
class="text-center mt-3"
v-if="
@@ -20,11 +20,13 @@
</Button>
</div>
<MessagesSkeleton :count="10" v-if="conversationStore.messages.loading" />
<TransitionGroup
v-else
enter-active-class="animate-slide-in"
tag="div"
class="space-y-4"
v-if="!conversationStore.messages.loading"
>
<div
v-for="message in conversationStore.conversationMessages"
@@ -43,7 +45,6 @@
</div>
</div>
</TransitionGroup>
<MessagesSkeleton :count="20" v-else />
</div>
</div>

View File

@@ -1,17 +1,13 @@
<template>
<div class="flex flex-wrap gap-2 px-2 py-1">
<div class="flex flex-wrap">
<TransitionGroup name="attachment-list" tag="div" class="flex flex-wrap gap-2">
<div
v-for="attachment in allAttachments"
:key="attachment.uuid || attachment.tempId"
class="flex items-center bg-white border border-gray-200 rounded shadow-sm transition-all duration-300 ease-in-out hover:shadow-md group"
class="flex items-center bg-white border border-gray-200 rounded shadow-sm transition-all duration-300 ease-in-out hover:shadow-md group px-2 gap-2"
>
<div class="flex items-center space-x-2 px-3 py-2">
<span v-if="attachment.loading" class="dot-loader">
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
</span>
<div class="flex items-center space-x-1 py-1">
<DotLoader v-if="attachment.loading"/>
<PaperclipIcon v-else size="16" class="text-gray-500 group-hover:text-primary" />
<Tooltip>
@@ -20,22 +16,21 @@
class="max-w-[12rem] overflow-hidden text-ellipsis whitespace-nowrap text-sm font-medium text-primary group-hover:text-gray-900"
>
{{ getAttachmentName(attachment.filename) }}
<span class="text-xs text-gray-500 ml-1">
{{ formatBytes(attachment.size) }}
</span>
</div>
</TooltipTrigger>
<TooltipContent>
<p class="text-sm">{{ attachment.filename }}</p>
</TooltipContent>
</Tooltip>
<span class="text-xs text-gray-500">
{{ formatBytes(attachment.size) }}
</span>
</div>
<button
v-if="!attachment.loading"
@click.stop="onDelete(attachment.uuid)"
class="p-2 text-gray-400 hover:text-red-500 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50 rounded transition-colors duration-300 ease-in-out"
class="text-gray-400 hover:text-red-500 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50 rounded transition-colors duration-300 ease-in-out"
title="Remove attachment"
>
<X size="14" />
@@ -49,6 +44,7 @@
import { computed } from 'vue'
import { formatBytes } from '@/utils/file.js'
import { X, Paperclip as PaperclipIcon } from 'lucide-vue-next'
import { DotLoader } from '@/components/ui/loader'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
const props = defineProps({
@@ -85,13 +81,13 @@ const getAttachmentName = (name) => {
.attachment-list-move,
.attachment-list-enter-active,
.attachment-list-leave-active {
transition: all 0.5s ease;
transition: all 0.3s ease;
}
.attachment-list-enter-from,
.attachment-list-leave-to {
opacity: 0;
transform: translateX(30px);
transform: translateX(10px);
}
.attachment-list-leave-active {

View File

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

View File

@@ -16,33 +16,32 @@
size="16"
/>
</div>
<div>
<h4 class="mt-3">
<span v-if="conversationStore.conversation.loading">
<Skeleton class="w-32 h-4" />
</span>
<span v-else>
{{ conversation?.contact?.first_name + ' ' + conversation?.contact?.last_name }}
</span>
</h4>
<p class="text-sm text-muted-foreground flex gap-2 mt-1">
<Mail class="size-3 mt-1" />
<span v-if="conversationStore.conversation.loading">
<Skeleton class="w-32 h-4" />
</span>
<span v-else>
{{ conversation?.contact?.email }}
</span>
</p>
<p class="text-sm text-muted-foreground flex gap-2 mt-1">
<Phone class="size-3 mt-1" />
<span v-if="conversationStore.conversation.loading">
<Skeleton class="w-32 h-4" />
</span>
<span v-else>
{{ conversation?.contact?.phone_number || 'Not available' }}
</span>
</p>
<div class="mt-3 h-6">
<span v-if="conversationStore.conversation.loading">
<Skeleton class="w-24 h-4" />
</span>
<span v-else>
{{ conversation?.contact?.first_name + ' ' + conversation?.contact?.last_name }}
</span>
</div>
<div class="text-sm text-muted-foreground flex gap-2 h-4 mt-2">
<Mail class="size-3 mt-1" />
<span v-if="conversationStore.conversation.loading">
<Skeleton class="w-32 h-4" />
</span>
<span v-else>
{{ conversation?.contact?.email }}
</span>
</div>
<div class="text-sm text-muted-foreground flex gap-2 mt-2 h-4">
<Phone class="size-3 mt-1" />
<span v-if="conversationStore.conversation.loading">
<Skeleton class="w-32 h-4" />
</span>
<span v-else>
{{ conversation?.contact?.phone_number || 'Not available' }}
</span>
</div>
</div>
</template>

View File

@@ -1,16 +0,0 @@
<template>
<div class="flex flex-col space-y-6" v-if="userStore.getFullName">
<div>
<span class="font-medium text-xl space-y-1">
<p class="font-semibold text-2xl">Hi, {{ userStore.getFullName }}</p>
<p class="text-muted-foreground text-lg">🌤 {{ format(new Date(), 'EEEE, MMMM d, HH:mm a') }}</p>
</span>
</div>
</div>
</template>
<script setup>
import { format } from 'date-fns'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
</script>

View File

@@ -1,5 +1,5 @@
<template>
<div class="flex items-center space-x-4 p-4">
<div class="flex items-center space-x-4 px-2 h-12">
<SidebarTrigger class="cursor-pointer w-4 h-4 text-black" />
<div class="flex-1 flex items-center">
<Search class="w-5 h-5" />

View File

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

View File

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

View File

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

View File

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

View File

@@ -545,10 +545,12 @@ export const useConversationStore = defineStore('conversation', () => {
if (listConversation) {
listConversation.last_message = message.content
listConversation.last_message_at = message.created_at
listConversation.last_message_sender = message.sender_type
if (listConversation.uuid !== conversation?.data?.uuid) {
listConversation.unread_message_count += 1
}
} else {
// Conversation is not in the list, fetch the first page of the conversations list as this updated conversation might be at the top.
fetchFirstPageConversations()
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -34,7 +34,7 @@
</template>
<template #help>
<p>Add custom conversation statuses to extend default workflow.</p>
<p>Create custom conversation statuses to extend default workflow.</p>
</template>
</AdminPageWithHelp>
</template>

View File

@@ -32,7 +32,7 @@
</template>
<template #help>
<p>Create and organize tags to categorize conversations.</p>
<p>Tags help you categorize your conversations. Create or edit tags here.</p>
</template>
</AdminPageWithHelp>
</template>

View File

@@ -19,8 +19,8 @@ const formLoading = ref(false)
const router = useRouter()
const emitter = useEmitter()
const breadcrumbLinks = [
{ path: '/admin/teams/teams', label: 'Teams' },
{ path: '/admin/teams/teams/new', label: 'New team' }
{ path: 'team-list', label: 'Teams' },
{ path: '', label: 'New team' }
]
const submitForm = (values) => {
@@ -35,7 +35,7 @@ const createTeam = async (values) => {
title: 'Success',
description: "Team created successfully"
})
router.push('/admin/teams/teams')
router.push({ name: 'team-list' })
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',

View File

@@ -23,8 +23,8 @@ const isLoading = ref(false)
const breadcrumbLinks = [
{ path: '/admin/teams/teams', label: 'Teams' },
{ path: '#', label: 'Edit team' }
{ path: 'team-list', label: 'Teams' },
{ path: '', label: 'Edit team' }
]
const props = defineProps({

View File

@@ -2,7 +2,9 @@
<Spinner v-if="isLoading" />
<div :class="{ 'transition-opacity duration-300 opacity-50': isLoading }">
<div class="flex justify-end mb-5">
<Button @click="navigateToAddTeam"> New team </Button>
<router-link :to="{ name: 'new-team' }">
<Button> New team </Button>
</router-link>
</div>
<div>
<DataTable :columns="columns" :data="data" />
@@ -15,7 +17,6 @@ import { ref, onMounted, onUnmounted } from 'vue'
import { handleHTTPError } from '@/utils/http'
import { columns } from '@/features/admin/teams/TeamsDataTableColumns.js'
import { Button } from '@/components/ui/button'
import { useRouter } from 'vue-router'
import { Spinner } from '@/components/ui/spinner'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
@@ -23,7 +24,6 @@ import DataTable from '@/components/datatable/DataTable.vue'
import api from '@/api'
const emitter = useEmitter()
const router = useRouter()
const data = ref([])
const isLoading = ref(false)
@@ -43,10 +43,6 @@ const getData = async () => {
}
}
const navigateToAddTeam = () => {
router.push('/admin/teams/teams/new')
}
const listenForRefresh = () => {
emitter.on(EMITTER_EVENTS.REFRESH_LIST, (event) => {
if (event.model === 'team') {

View File

@@ -6,7 +6,7 @@
<template #help>
<p>Configure team settings including working hours and SLA policies.</p>
<p>Manage agent auto-assignment rules for team efficiency.</p>
<p>Manage agent auto-assignment limits and more.</p>
</template>
</AdminPageWithHelp>
</template>

View File

@@ -46,7 +46,7 @@ const submitForm = async (values) => {
} else {
await api.createTemplate(values)
toastDescription = 'Template created successfully'
router.push('/admin/templates')
router.push({ name: 'templates' })
emitter.emit(EMITTER_EVENTS.REFRESH_LIST, {
model: 'templates'
})
@@ -71,8 +71,8 @@ const breadCrumLabel = () => {
}
const breadcrumbLinks = [
{ path: '/admin/templates', label: 'Templates' },
{ path: '#', label: breadCrumLabel() }
{ path: 'templates', label: 'Templates' },
{ path: '', label: breadCrumLabel() }
]
onMounted(async () => {

View File

@@ -35,7 +35,7 @@
<template #help>
<p>Design templates for customer communications and responses.</p>
<p>Configure internal team notification templates.</p>
<p>Modify content for internal and external emails.</p>
</template>
</AdminPageWithHelp>
</template>

View File

@@ -19,8 +19,8 @@ const emitter = useEmitter()
const router = useRouter()
const formLoading = ref(false)
const breadcrumbLinks = [
{ path: '/admin/teams/users', label: 'Users' },
{ path: '#', label: 'Add user' }
{ path: 'user-list', label: 'Users' },
{ path: '', label: 'Add user' }
]
const onSubmit = (values) => {
@@ -35,7 +35,7 @@ const createNewUser = async (values) => {
title: 'Success',
description: 'User created successfully'
})
router.push('/admin/teams/users')
router.push({ name: 'user-list' })
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',

View File

@@ -22,9 +22,8 @@ const formLoading = ref(false)
const emitter = useEmitter()
const breadcrumbLinks = [
{ path: '/admin/teams/users', label: 'Users' },
{ path: '#', label: 'Edit user' }
{ path: 'user-list', label: 'Users' },
{ path: '', label: 'Edit user' }
]
const submitForm = (values) => {

View File

@@ -1,6 +1,6 @@
<template>
<div
class="page-content p-4 pr-36"
class="overflow-y-auto p-4 pr-36"
:class="{ 'opacity-50 transition-opacity duration-300': isLoading }"
>
<Spinner v-if="isLoading" />
@@ -113,13 +113,28 @@ const getDashboardCharts = async () => {
chartData.value.new_conversations = resp.data.data.new_conversations || []
chartData.value.resolved_conversations = resp.data.data.resolved_conversations || []
chartData.value.messages_sent = resp.data.data.messages_sent || []
chartData.value.processedData = resp.data.data.new_conversations.map((item) => ({
date: item.date,
'New conversations': item.count,
// Get all dates from all datasets
const allDates = [
...chartData.value.new_conversations.map((item) => item.date),
...chartData.value.resolved_conversations.map((item) => item.date),
...chartData.value.messages_sent.map((item) => item.date)
]
// Create unique sorted dates
const uniqueDates = [...new Set(allDates)].sort((a, b) => new Date(a) - new Date(b))
// Process data for all dates
chartData.value.processedData = uniqueDates.map((date) => ({
date,
'New conversations':
chartData.value.new_conversations.find((item) => item.date === date)?.count || 0,
'Resolved conversations':
resp.data.data.resolved_conversations.find((r) => r.date === item.date)?.count || 0,
'Messages sent': resp.data.data.messages_sent.find((r) => r.date === item.date)?.count || 0
chartData.value.resolved_conversations.find((item) => item.date === date)?.count || 0,
'Messages sent':
chartData.value.messages_sent.find((item) => item.date === date)?.count || 0
}))
chartData.value.status_summary = resp.data.data.status_summary || []
})
.catch((error) => {

View File

@@ -55,6 +55,7 @@ export class WebSocketClient {
const data = JSON.parse(event.data)
const handlers = {
// On new message, update the message in the conversation list and in the currently opened conversation.
[WS_EVENT.NEW_MESSAGE]: () => {
this.convStore.updateConversationList(data.data)
this.convStore.updateConversationMessage(data.data)
@@ -120,8 +121,8 @@ export class WebSocketClient {
if (this.socket?.readyState === WebSocket.OPEN) {
try {
this.socket.send('ping')
if (Date.now() - this.lastPong > 10000) {
console.warn('No pong received in 10 seconds, closing connection')
if (Date.now() - this.lastPong > 60000) {
console.warn('No pong received in 60 seconds, closing connection')
this.socket.close()
}
} catch (e) {

View File

@@ -1,10 +1,16 @@
import { fileURLToPath, URL } from 'node:url'
import autoprefixer from 'autoprefixer'
import tailwind from 'tailwindcss'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
export default defineConfig({
css: {
postcss: {
plugins: [tailwind(), autoprefixer()],
},
},
server: {
port: 8000,
proxy: {

8
go.mod
View File

@@ -34,7 +34,7 @@ require (
github.com/zerodha/logf v0.5.5
github.com/zerodha/simplesessions/stores/redis/v3 v3.0.0
github.com/zerodha/simplesessions/v3 v3.0.0
golang.org/x/crypto v0.25.0
golang.org/x/crypto v0.31.0
golang.org/x/oauth2 v0.21.0
)
@@ -70,8 +70,8 @@ require (
github.com/stretchr/testify v1.9.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
golang.org/x/image v0.18.0 // indirect
golang.org/x/net v0.27.0 // indirect
golang.org/x/sys v0.22.0 // indirect
golang.org/x/text v0.16.0 // indirect
golang.org/x/net v0.33.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

16
go.sum
View File

@@ -180,8 +180,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
@@ -196,8 +196,8 @@ golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qx
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -214,8 +214,8 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -226,8 +226,8 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=

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