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

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 # 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** > **CAUTION:** This project is currently in **alpha**. Features and APIs may change and are not yet fully tested.
- **pnpm**
- **PostgreSQL >= 13**
- **Redis**
1. **Clone the repository**: ## Features
```bash - **Multi Inbox**
git clone https://github.com/abhinavxd/libredesk.git Libredesk supports multiple inboxes, letting you manage conversations across teams effortlessly.
cd libredesk - **Granular Permissions**
``` Create custom roles with granular permissions for teams and individual agents.
- **Smart Automation**
Eliminate repetitive tasks with powerful automation rules. Auto-tag, assign, and route conversations based on custom conditions.
- **CSAT Surveys**
Measure customer satisfaction with automated surveys.
- **Macros**
Save frequently sent messages as templates. With one click, send saved responses, set tags, and more.
- **Smart Organization**
Keep conversations organized with tags, custom statuses for conversations, and snoozing. Find any conversation instantly from the search bar.
- **Auto Assignment**
Distribute workload with auto assignment rules. Auto-assign conversations based on agent capacity or custom criteria.
- **SLA Management**
Set and track response time targets. Get notified when conversations are at risk of breaching SLA commitments.
- **Business Intelligence**
Connect your favorite BI tools like Metabase and create custom dashboards and reports with your support data—without lock-ins.
- **AI-Assisted Response Rewrite**
Instantly rewrite responses with AI to make them more friendly, professional, or polished.
- **Command Bar**
Opens with a simple shortcut (CTRL+k) and lets you quickly perform actions on conversations.
2. **Configure the Application**: And more checkout - [libredesk.io](https://libredesk.io)
- Copy the sample configuration file `config.toml.sample` to `config.toml`:
```bash
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` ### Docker
- Frontend: `make run-frontend`
--- 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. // OpenID connect single sign-on.
g.GET("/api/v1/oidc/enabled", handleGetAllEnabledOIDC) g.GET("/api/v1/oidc/enabled", handleGetAllEnabledOIDC)
g.GET("/api/v1/oidc", perm(handleGetAllOIDC, "oidc:manage")) g.GET("/api/v1/oidc", perm(handleGetAllOIDC, "oidc:manage"))
g.GET("/api/v1/oidc/{id}", perm(handleGetOIDC, "oidc:manage"))
g.POST("/api/v1/oidc", perm(handleCreateOIDC, "oidc:manage")) g.POST("/api/v1/oidc", perm(handleCreateOIDC, "oidc:manage"))
g.POST("/api/v1/oidc/test", perm(handleTestOIDC, "oidc:manage"))
g.GET("/api/v1/oidc/{id}", perm(handleGetOIDC, "oidc:manage"))
g.PUT("/api/v1/oidc/{id}", perm(handleUpdateOIDC, "oidc:manage")) g.PUT("/api/v1/oidc/{id}", perm(handleUpdateOIDC, "oidc:manage"))
g.DELETE("/api/v1/oidc/{id}", perm(handleDeleteOIDC, "oidc:manage")) g.DELETE("/api/v1/oidc/{id}", perm(handleDeleteOIDC, "oidc:manage"))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,16 +9,16 @@ address = "0.0.0.0:9000"
socket = "" socket = ""
read_timeout = "5s" read_timeout = "5s"
write_timeout = "5s" write_timeout = "5s"
max_body_size = 10000000 max_body_size = 500000000
keepalive_timeout = "10s" keepalive_timeout = "10s"
# File upload provider. # File upload provider to use, either `fs` or `s3`.
[upload] [upload]
provider = "fs" provider = "fs"
# Filesytem provider. # Filesytem provider.
[upload.fs] [upload.fs]
upload_path = '/home/ubuntu/uploads' upload_path = 'uploads'
# S3 provider. # S3 provider.
[upload.s3] [upload.s3]
@@ -32,6 +32,7 @@ expiry = "6h"
# Postgres. # Postgres.
[db] [db]
# If using docker compose, use the service name as the host. e.g. db
host = "127.0.0.1" host = "127.0.0.1"
port = 5432 port = 5432
user = "postgres" user = "postgres"
@@ -44,6 +45,7 @@ max_lifetime = "300s"
# Redis. # Redis.
[redis] [redis]
# If using docker compose, use the service name as the host. e.g. redis
address = "127.0.0.1:6379" address = "127.0.0.1:6379"
password = "" password = ""
db = 0 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.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link <link
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap" href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&display=swap"
rel="stylesheet"> rel="stylesheet">
</head> </head>

View File

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

1640
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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> <template>
<div class="flex flex-col items-center justify-center text-gray-600 dark:text-gray-300"> <span class="dot-loader">
<span class="dot-loader"> <span class="dot"></span>
<span class="dot"></span> <span class="dot"></span>
<span class="dot"></span> <span class="dot"></span>
<span class="dot"></span> </span>
</span>
</div>
</template> </template>

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 = { export const CONVERSATION_DEFAULT_STATUSES = {
OPEN: 'Open', OPEN: 'Open',
IN_PROGRESS: 'In Progress',
WAITING: 'Waiting',
SNOOZED: 'Snoozed', SNOOZED: 'Snoozed',
RESOLVED: 'Resolved', RESOLVED: 'Resolved',
CLOSED: 'Closed', CLOSED: 'Closed',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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> <template>
<div class="flex items-center space-x-4 p-4"> <div class="flex items-center space-x-4 px-2 h-12">
<SidebarTrigger class="cursor-pointer w-4 h-4 text-black" /> <SidebarTrigger class="cursor-pointer w-4 h-4 text-black" />
<div class="flex-1 flex items-center"> <div class="flex-1 flex items-center">
<Search class="w-5 h-5" /> <Search class="w-5 h-5" />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -32,7 +32,7 @@
</template> </template>
<template #help> <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> </template>
</AdminPageWithHelp> </AdminPageWithHelp>
</template> </template>

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,7 @@
<template #help> <template #help>
<p>Configure team settings including working hours and SLA policies.</p> <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> </template>
</AdminPageWithHelp> </AdminPageWithHelp>
</template> </template>

View File

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

View File

@@ -35,7 +35,7 @@
<template #help> <template #help>
<p>Design templates for customer communications and responses.</p> <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> </template>
</AdminPageWithHelp> </AdminPageWithHelp>
</template> </template>

View File

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

View File

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

View File

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

View File

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

View File

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

8
go.mod
View File

@@ -34,7 +34,7 @@ require (
github.com/zerodha/logf v0.5.5 github.com/zerodha/logf v0.5.5
github.com/zerodha/simplesessions/stores/redis/v3 v3.0.0 github.com/zerodha/simplesessions/stores/redis/v3 v3.0.0
github.com/zerodha/simplesessions/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 golang.org/x/oauth2 v0.21.0
) )
@@ -70,8 +70,8 @@ require (
github.com/stretchr/testify v1.9.0 // indirect github.com/stretchr/testify v1.9.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
golang.org/x/image v0.18.0 // indirect golang.org/x/image v0.18.0 // indirect
golang.org/x/net v0.27.0 // indirect golang.org/x/net v0.33.0 // indirect
golang.org/x/sys v0.22.0 // indirect golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.16.0 // indirect golang.org/x/text v0.21.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // 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-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-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.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.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= 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.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 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= 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-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.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.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.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= 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 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= 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= 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-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.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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 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-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.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 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.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.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.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= 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-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-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 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