mirror of
https://github.com/abhinavxd/libredesk.git
synced 2025-10-24 00:23:36 +00:00
Compare commits
44 Commits
mvp
...
v0.1.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e729b91ef | ||
|
|
edd629276d | ||
|
|
94e9f0f3de | ||
|
|
29798c9ba0 | ||
|
|
cadf26c8b5 | ||
|
|
8358455478 | ||
|
|
5d38747bdd | ||
|
|
5f3b0c3415 | ||
|
|
13f0d2003c | ||
|
|
afc2ff45df | ||
|
|
605c0aa7a1 | ||
|
|
5da727350b | ||
|
|
ef077aeac8 | ||
|
|
2558f97f0a | ||
|
|
501027a0b2 | ||
|
|
cc38d8825d | ||
|
|
5361bcb24f | ||
|
|
730740094f | ||
|
|
49761960fd | ||
|
|
41c6ebe003 | ||
|
|
2ae85ac76a | ||
|
|
1a7f53628b | ||
|
|
0649633878 | ||
|
|
d2a79d9a10 | ||
|
|
aba849d344 | ||
|
|
3cb584c4d6 | ||
|
|
8567baa0e1 | ||
|
|
b601724b0a | ||
|
|
01c136c469 | ||
|
|
a8c61074bb | ||
|
|
6324651d01 | ||
|
|
62e38814c7 | ||
|
|
7eb365c04a | ||
|
|
83460ab6a3 | ||
|
|
1e44bbbde5 | ||
|
|
1f70884628 | ||
|
|
f5a4813830 | ||
|
|
a2e320473d | ||
|
|
2c8900ed95 | ||
|
|
2d4356e4f5 | ||
|
|
dbb2ae303f | ||
|
|
67a7427ab0 | ||
|
|
8392371ebf | ||
|
|
b8e38424d5 |
62
.github/workflows/release.yml
vendored
Normal file
62
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
with:
|
||||||
|
driver: docker-container
|
||||||
|
cache-to: type=gha
|
||||||
|
cache-from: type=gha
|
||||||
|
|
||||||
|
- name: Log in to Docker Hub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Log in to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: "1.21"
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: '18.12'
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
run: npm install -g pnpm
|
||||||
|
|
||||||
|
- name: Run GoReleaser
|
||||||
|
uses: goreleaser/goreleaser-action@v5
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
args: release --parallelism 1 --clean
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
DOCKER_ORG: libredesk
|
||||||
|
GITHUB_ORG: ${{ github.repository_owner }}
|
||||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -1,5 +1,9 @@
|
|||||||
node_modules
|
node_modules
|
||||||
config.toml
|
config.toml
|
||||||
|
config.toml.*
|
||||||
libredesk.bin
|
libredesk.bin
|
||||||
uploads/*
|
libredesk
|
||||||
.env
|
libredesk.exe
|
||||||
|
uploads
|
||||||
|
.env
|
||||||
|
dist/
|
||||||
198
.goreleaser.yaml
Normal file
198
.goreleaser.yaml
Normal 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
18
Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Use the latest version of Alpine Linux as the base image
|
||||||
|
FROM alpine:latest
|
||||||
|
|
||||||
|
# Install necessary packages
|
||||||
|
RUN apk --no-cache add ca-certificates
|
||||||
|
|
||||||
|
# Set the working directory to /libredesk
|
||||||
|
WORKDIR /libredesk
|
||||||
|
|
||||||
|
# Copy necessary files
|
||||||
|
COPY libredesk .
|
||||||
|
COPY config.sample.toml config.toml
|
||||||
|
|
||||||
|
# Expose port 9000 for the application
|
||||||
|
EXPOSE 9000
|
||||||
|
|
||||||
|
# Set the default command to run the libredesk binary
|
||||||
|
CMD ["./libredesk"]
|
||||||
8
Makefile
8
Makefile
@@ -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
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ 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}' -X 'main.buildDate=${LAST_COMMIT_DATE}' -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
|
||||||
|
|||||||
25
README.md
25
README.md
@@ -1,17 +1,27 @@
|
|||||||
|
<a href="https://zerodha.tech"><img src="https://zerodha.tech/static/images/github-badge.svg" align="right" /></a>
|
||||||
|
|
||||||
|
|
||||||
# Libredesk
|
# Libredesk
|
||||||
|
|
||||||
Open-source, self-hosted customer support desk. Single binary app.
|
Open source, self-hosted customer support desk. Single binary app.
|
||||||
|
|
||||||
|
Visit [libredesk.io](https://libredesk.io) for more info. Check out the [**Live demo**](https://demo.libredesk.io/).
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
> This project is currently in **alpha**. Features and APIs may change and are not yet fully tested.
|
> This project is currently in **alpha**. Features and APIs may change and are not yet fully tested.
|
||||||
|
|
||||||
|
|
||||||
## Developer Setup
|
## Developer Setup
|
||||||
|
|
||||||
#### Prerequisites
|
#### Prerequisites
|
||||||
|
|
||||||
- **go**
|
- **go**
|
||||||
- **pnpm**
|
- **pnpm**
|
||||||
- **PostgreSQL >= 13**
|
- **postgreSQL >= 13**
|
||||||
- **Redis**
|
- **redis**
|
||||||
|
|
||||||
1. **Clone the repository**:
|
1. **Clone the repository**:
|
||||||
|
|
||||||
@@ -20,20 +30,17 @@ Open-source, self-hosted customer support desk. Single binary app.
|
|||||||
cd libredesk
|
cd libredesk
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Configure the Application**:
|
2. **Create config file**:
|
||||||
|
|
||||||
- Copy the sample configuration file `config.toml.sample` to `config.toml`:
|
- Copy the sample configuration file `config.toml.sample` to `config.toml`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp config.toml.sample config.toml
|
cp config.toml.sample config.toml
|
||||||
```
|
```
|
||||||
- Edit the `config.toml` file to configure your database and Redis connection settings.
|
- Edit the `config.toml` file to configure your postgres and redis connection settings.
|
||||||
|
|
||||||
3. **Run in Development Mode**:
|
3. **Run in development mode**:
|
||||||
|
|
||||||
- Backend: `make run-backend`
|
- Backend: `make run-backend`
|
||||||
- Frontend: `make run-frontend`
|
- Frontend: `make run-frontend`
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Visit [libredesk.io](https://libredesk.io) for more info.
|
|
||||||
|
|||||||
@@ -37,8 +37,9 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
|
|||||||
// OpenID connect single sign-on.
|
// OpenID connect single sign-on.
|
||||||
g.GET("/api/v1/oidc/enabled", handleGetAllEnabledOIDC)
|
g.GET("/api/v1/oidc/enabled", handleGetAllEnabledOIDC)
|
||||||
g.GET("/api/v1/oidc", perm(handleGetAllOIDC, "oidc:manage"))
|
g.GET("/api/v1/oidc", perm(handleGetAllOIDC, "oidc:manage"))
|
||||||
g.GET("/api/v1/oidc/{id}", perm(handleGetOIDC, "oidc:manage"))
|
|
||||||
g.POST("/api/v1/oidc", perm(handleCreateOIDC, "oidc:manage"))
|
g.POST("/api/v1/oidc", perm(handleCreateOIDC, "oidc:manage"))
|
||||||
|
g.POST("/api/v1/oidc/test", perm(handleTestOIDC, "oidc:manage"))
|
||||||
|
g.GET("/api/v1/oidc/{id}", perm(handleGetOIDC, "oidc:manage"))
|
||||||
g.PUT("/api/v1/oidc/{id}", perm(handleUpdateOIDC, "oidc:manage"))
|
g.PUT("/api/v1/oidc/{id}", perm(handleUpdateOIDC, "oidc:manage"))
|
||||||
g.DELETE("/api/v1/oidc/{id}", perm(handleDeleteOIDC, "oidc:manage"))
|
g.DELETE("/api/v1/oidc/{id}", perm(handleDeleteOIDC, "oidc:manage"))
|
||||||
|
|
||||||
|
|||||||
@@ -98,6 +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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
10
cmd/main.go
10
cmd/main.go
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
36
cmd/oidc.go
36
cmd/oidc.go
@@ -44,6 +44,19 @@ func handleGetOIDC(r *fastglue.Request) error {
|
|||||||
return r.SendEnvelope(o)
|
return r.SendEnvelope(o)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleTestOIDC tests an OIDC provider URL by doing a discovery on the provider URL.
|
||||||
|
func handleTestOIDC(r *fastglue.Request) error {
|
||||||
|
var (
|
||||||
|
app = r.Context.(*App)
|
||||||
|
providerURL = string(r.RequestCtx.PostArgs().Peek("provider_url"))
|
||||||
|
)
|
||||||
|
if err := app.auth.TestProvider(providerURL); err != nil {
|
||||||
|
return sendErrorEnvelope(r, err)
|
||||||
|
}
|
||||||
|
return r.SendEnvelope("OIDC provider discovered successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCreateOIDC creates a new OIDC record.
|
||||||
func handleCreateOIDC(r *fastglue.Request) error {
|
func handleCreateOIDC(r *fastglue.Request) error {
|
||||||
var (
|
var (
|
||||||
app = r.Context.(*App)
|
app = r.Context.(*App)
|
||||||
@@ -52,18 +65,19 @@ func handleCreateOIDC(r *fastglue.Request) error {
|
|||||||
if err := r.Decode(&req, "json"); err != nil {
|
if err := r.Decode(&req, "json"); err != nil {
|
||||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Bad request", nil, envelope.GeneralError)
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Bad request", nil, envelope.GeneralError)
|
||||||
}
|
}
|
||||||
err := app.oidc.Create(req)
|
|
||||||
if err != nil {
|
if err := app.oidc.Create(req); err != nil {
|
||||||
return sendErrorEnvelope(r, err)
|
return sendErrorEnvelope(r, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reload the auth manager to update the OIDC providers.
|
// Reload the auth manager to update the OIDC providers.
|
||||||
if err := reloadAuth(app); err != nil {
|
if err := reloadAuth(app); err != nil {
|
||||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error reloading auth", nil, envelope.GeneralError)
|
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, envelope.GeneralError)
|
||||||
}
|
}
|
||||||
return r.SendEnvelope("OIDC created successfully")
|
return r.SendEnvelope("OIDC created successfully")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleUpdateOIDC updates an OIDC record.
|
||||||
func handleUpdateOIDC(r *fastglue.Request) error {
|
func handleUpdateOIDC(r *fastglue.Request) error {
|
||||||
var (
|
var (
|
||||||
app = r.Context.(*App)
|
app = r.Context.(*App)
|
||||||
@@ -79,8 +93,7 @@ func handleUpdateOIDC(r *fastglue.Request) error {
|
|||||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Bad request", nil, envelope.GeneralError)
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Bad request", nil, envelope.GeneralError)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = app.oidc.Update(id, req)
|
if err = app.oidc.Update(id, req); err != nil {
|
||||||
if err != nil {
|
|
||||||
return sendErrorEnvelope(r, err)
|
return sendErrorEnvelope(r, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,23 +104,16 @@ func handleUpdateOIDC(r *fastglue.Request) error {
|
|||||||
return r.SendEnvelope("OIDC updated successfully")
|
return r.SendEnvelope("OIDC updated successfully")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleDeleteOIDC deletes an OIDC record.
|
||||||
func handleDeleteOIDC(r *fastglue.Request) error {
|
func handleDeleteOIDC(r *fastglue.Request) error {
|
||||||
var (
|
var app = r.Context.(*App)
|
||||||
app = r.Context.(*App)
|
|
||||||
)
|
|
||||||
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
||||||
if err != nil || id == 0 {
|
if err != nil || id == 0 {
|
||||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
|
||||||
"Invalid oidc `id`.", nil, envelope.InputError)
|
"Invalid oidc `id`.", nil, envelope.InputError)
|
||||||
}
|
}
|
||||||
err = app.oidc.Delete(id)
|
if err = app.oidc.Delete(id); err != nil {
|
||||||
if err != nil {
|
|
||||||
return sendErrorEnvelope(r, err)
|
return sendErrorEnvelope(r, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reload the auth manager to update the OIDC providers.
|
|
||||||
if err := reloadAuth(app); err != nil {
|
|
||||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, envelope.GeneralError)
|
|
||||||
}
|
|
||||||
return r.SendEnvelope("OIDC deleted successfully")
|
return r.SendEnvelope("OIDC deleted successfully")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
[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.
|
||||||
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.
|
||||||
address = "127.0.0.1:6379"
|
address = "127.0.0.1:6379"
|
||||||
password = ""
|
password = ""
|
||||||
db = 0
|
db = 0
|
||||||
|
|||||||
62
docker-compose.yml
Normal file
62
docker-compose.yml
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
services:
|
||||||
|
# Libredesk app
|
||||||
|
app:
|
||||||
|
image: libredesk: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:
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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%;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ export const CONVERSATION_LIST_TYPE = {
|
|||||||
|
|
||||||
export const CONVERSATION_DEFAULT_STATUSES = {
|
export const CONVERSATION_DEFAULT_STATUSES = {
|
||||||
OPEN: 'Open',
|
OPEN: 'Open',
|
||||||
IN_PROGRESS: 'In Progress',
|
|
||||||
WAITING: 'Waiting',
|
|
||||||
SNOOZED: 'Snoozed',
|
SNOOZED: 'Snoozed',
|
||||||
RESOLVED: 'Resolved',
|
RESOLVED: 'Resolved',
|
||||||
CLOSED: 'Closed',
|
CLOSED: 'Closed',
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ export const adminNavItems = [
|
|||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
title: 'SSO',
|
title: 'SSO',
|
||||||
href: '/admin/oidc',
|
href: '/admin/sso',
|
||||||
permission: 'oidc:manage'
|
permission: 'oidc:manage'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -120,7 +120,7 @@
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
<Button type="submit" :disabled="isLoading">{{ submitLabel }}</Button>
|
<Button type="submit" :disabled="isLoading" :isLoading="isLoading">{{ submitLabel }}</Button>
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<form
|
<form @submit="onSubmit" class="space-y-6 w-full">
|
||||||
@submit="onSubmit"
|
|
||||||
class="space-y-6 w-full"
|
|
||||||
>
|
|
||||||
<FormField v-slot="{ field }" name="site_name">
|
<FormField v-slot="{ field }" name="site_name">
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Site Name</FormLabel>
|
<FormLabel>Site Name</FormLabel>
|
||||||
@@ -126,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>
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ export const formSchema = z.object({
|
|||||||
.min(1, {
|
.min(1, {
|
||||||
message: 'Max upload file size must be at least 1 MB.'
|
message: 'Max upload file size must be at least 1 MB.'
|
||||||
})
|
})
|
||||||
.max(30, {
|
.max(500, {
|
||||||
message: 'Max upload file size cannot exceed 30 MB.'
|
message: 'Max upload file size cannot exceed 500 MB.'
|
||||||
}),
|
}),
|
||||||
allowed_file_upload_extensions: z.array(z.string()).nullable().default([]).optional()
|
allowed_file_upload_extensions: z.array(z.string()).nullable().default([]).optional()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,7 +7,9 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent>
|
<DropdownMenuContent>
|
||||||
<DropdownMenuItem @click="edit(props.role.id)">Edit</DropdownMenuItem>
|
<DropdownMenuItem :as-child="true">
|
||||||
|
<RouterLink :to="{ name: 'edit-sso', params: { id: props.role.id } }">Edit</RouterLink>
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem @click="() => (alertOpen = true)">Delete</DropdownMenuItem>
|
<DropdownMenuItem @click="() => (alertOpen = true)">Delete</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
@@ -49,12 +51,10 @@ import {
|
|||||||
AlertDialogTitle
|
AlertDialogTitle
|
||||||
} from '@/components/ui/alert-dialog'
|
} from '@/components/ui/alert-dialog'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import { useEmitter } from '@/composables/useEmitter'
|
import { useEmitter } from '@/composables/useEmitter'
|
||||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const emit = useEmitter()
|
const emit = useEmitter()
|
||||||
const alertOpen = ref(false)
|
const alertOpen = ref(false)
|
||||||
|
|
||||||
@@ -68,10 +68,6 @@ const props = defineProps({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function edit(id) {
|
|
||||||
router.push({ path: `/admin/oidc/${id}/edit` })
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
await api.deleteOIDC(props.role.id)
|
await api.deleteOIDC(props.role.id)
|
||||||
alertOpen.value = false
|
alertOpen.value = false
|
||||||
|
|||||||
@@ -1,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()
|
||||||
|
|
||||||
|
|||||||
@@ -120,6 +120,7 @@
|
|||||||
:handleInlineImageUpload="handleInlineImageUpload"
|
:handleInlineImageUpload="handleInlineImageUpload"
|
||||||
:isBold="isBold"
|
:isBold="isBold"
|
||||||
:isItalic="isItalic"
|
:isItalic="isItalic"
|
||||||
|
:isSending="isSending"
|
||||||
@toggleBold="toggleBold"
|
@toggleBold="toggleBold"
|
||||||
@toggleItalic="toggleItalic"
|
@toggleItalic="toggleItalic"
|
||||||
:enableSend="enableSend"
|
:enableSend="enableSend"
|
||||||
@@ -240,6 +241,7 @@
|
|||||||
:handleInlineImageUpload="handleInlineImageUpload"
|
:handleInlineImageUpload="handleInlineImageUpload"
|
||||||
:isBold="isBold"
|
:isBold="isBold"
|
||||||
:isItalic="isItalic"
|
:isItalic="isItalic"
|
||||||
|
:isSending="isSending"
|
||||||
@toggleBold="toggleBold"
|
@toggleBold="toggleBold"
|
||||||
@toggleItalic="toggleItalic"
|
@toggleItalic="toggleItalic"
|
||||||
:enableSend="enableSend"
|
:enableSend="enableSend"
|
||||||
@@ -276,6 +278,7 @@ const insertContent = ref(null)
|
|||||||
const setInlineImage = ref(null)
|
const setInlineImage = ref(null)
|
||||||
const clearEditorContent = ref(false)
|
const clearEditorContent = ref(false)
|
||||||
const isEditorFullscreen = ref(false)
|
const isEditorFullscreen = ref(false)
|
||||||
|
const isSending = ref(false)
|
||||||
const cursorPosition = ref(0)
|
const cursorPosition = ref(0)
|
||||||
const selectedText = ref('')
|
const selectedText = ref('')
|
||||||
const htmlContent = ref('')
|
const htmlContent = ref('')
|
||||||
@@ -464,6 +467,8 @@ const handleSend = async () => {
|
|||||||
|
|
||||||
isEditorFullscreen.value = false
|
isEditorFullscreen.value = false
|
||||||
try {
|
try {
|
||||||
|
isSending.value = true
|
||||||
|
|
||||||
// Send message if there is text content in the editor.
|
// Send message if there is text content in the editor.
|
||||||
if (hasTextContent.value) {
|
if (hasTextContent.value) {
|
||||||
// Replace inline image url with cid.
|
// Replace inline image url with cid.
|
||||||
@@ -517,6 +522,7 @@ const handleSend = async () => {
|
|||||||
description: handleHTTPError(error).message
|
description: handleHTTPError(error).message
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
|
isSending.value = false
|
||||||
clearEditorContent.value = true
|
clearEditorContent.value = true
|
||||||
conversationStore.resetMacro()
|
conversationStore.resetMacro()
|
||||||
conversationStore.resetMediaFiles()
|
conversationStore.resetMediaFiles()
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
<Smile class="h-4 w-4" />
|
<Smile class="h-4 w-4" />
|
||||||
</Toggle>
|
</Toggle>
|
||||||
</div>
|
</div>
|
||||||
<Button class="h-8 w-6 px-8" @click="handleSend" :disabled="!enableSend">Send</Button>
|
<Button class="h-8 w-6 px-8" @click="handleSend" :disabled="!enableSend" :isLoading="isSending">Send</Button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -57,6 +57,7 @@ const emit = defineEmits(['toggleBold', 'toggleItalic', 'emojiSelect'])
|
|||||||
defineProps({
|
defineProps({
|
||||||
isBold: Boolean,
|
isBold: Boolean,
|
||||||
isItalic: Boolean,
|
isItalic: Boolean,
|
||||||
|
isSending: Boolean,
|
||||||
enableSend: Boolean,
|
enableSend: Boolean,
|
||||||
handleSend: Function,
|
handleSend: Function,
|
||||||
handleFileUpload: Function,
|
handleFileUpload: Function,
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="h-screen flex flex-col">
|
<div class="h-screen flex flex-col">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<header class="border-b">
|
<div class="flex items-center space-x-4 px-2 h-12 border-b shrink-0">
|
||||||
<div class="flex items-center space-x-4 p-2">
|
<SidebarTrigger class="h-4 w-4" />
|
||||||
<SidebarTrigger class="h-4 w-4" />
|
<span class="text-xl font-semibold text-gray-800">{{ title }}</span>
|
||||||
<span class="text-xl font-semibold text-gray-800">{{ title }}</span>
|
</div>
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
<div class="bg-white px-2 py-2 flex justify-between items-center">
|
<div class="bg-white p-2 flex justify-between items-center">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" class="w-30">
|
<Button variant="ghost" class="w-30">
|
||||||
@@ -107,7 +105,7 @@
|
|||||||
|
|
||||||
<!-- Loading Skeleton -->
|
<!-- Loading Skeleton -->
|
||||||
<div v-if="isLoading" key="loading" class="space-y-4">
|
<div v-if="isLoading" key="loading" class="space-y-4">
|
||||||
<ConversationListItemSkeleton v-for="index in 10" :key="index" />
|
<ConversationListItemSkeleton v-for="index in 5" :key="index" />
|
||||||
</div>
|
</div>
|
||||||
</TransitionGroup>
|
</TransitionGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
<Reply class="inline-block w-4 h-4 mr-1.5 text-green-600 flex-shrink-0" />
|
<Reply
|
||||||
|
class="text-green-600 flex-shrink-0"
|
||||||
|
size="15"
|
||||||
|
v-if="conversation.last_message_sender === 'agent'"
|
||||||
|
/>
|
||||||
{{ trimmedLastMessage }}
|
{{ trimmedLastMessage }}
|
||||||
</p>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="conversation.unread_message_count > 0"
|
v-if="conversation.unread_message_count > 0"
|
||||||
class="flex items-center justify-center w-6 h-6 bg-green-600 text-white text-xs font-medium rounded-full"
|
class="flex items-center justify-center w-6 h-6 bg-green-600 text-white text-xs font-medium rounded-full"
|
||||||
|
|||||||
@@ -25,12 +25,12 @@
|
|||||||
<MessageAttachmentPreview :attachments="nonInlineAttachments" />
|
<MessageAttachmentPreview :attachments="nonInlineAttachments" />
|
||||||
|
|
||||||
<!-- Spinner for Pending Messages -->
|
<!-- Spinner for Pending Messages -->
|
||||||
<Spinner v-if="message.status === 'pending'" size="w-4 h-4" class="mt-2" />
|
<Spinner v-if="message.status === 'pending'" size="w-4 h-4" />
|
||||||
|
|
||||||
<!-- Icons -->
|
<!-- Icons -->
|
||||||
<div class="flex items-center space-x-2 mt-2">
|
<div class="flex items-center space-x-2 mt-2">
|
||||||
<Lock :size="10" v-if="isPrivateMessage" class="text-muted-foreground" />
|
<Lock :size="10" v-if="isPrivateMessage" class="text-muted-foreground" />
|
||||||
<CheckCheck :size="14" v-if="showCheckCheck" class="text-green-500" />
|
<Check :size="14" v-if="showCheckCheck" class="text-green-500" />
|
||||||
<RotateCcw
|
<RotateCcw
|
||||||
size="10"
|
size="10"
|
||||||
@click="retryMessage(message)"
|
@click="retryMessage(message)"
|
||||||
@@ -69,7 +69,7 @@
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import { useConversationStore } from '@/stores/conversation'
|
import { useConversationStore } from '@/stores/conversation'
|
||||||
import { Lock, RotateCcw, CheckCheck } from 'lucide-vue-next'
|
import { Lock, RotateCcw, Check } from 'lucide-vue-next'
|
||||||
import { revertCIDToImageSrc } from '@/utils/strings'
|
import { revertCIDToImageSrc } from '@/utils/strings'
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import { Spinner } from '@/components/ui/spinner'
|
import { Spinner } from '@/components/ui/spinner'
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col relative h-full">
|
<div class="flex flex-col relative h-full">
|
||||||
<div ref="threadEl" class="flex-1 overflow-y-auto" @scroll="handleScroll">
|
<div ref="threadEl" class="flex-1 overflow-y-auto" @scroll="handleScroll">
|
||||||
<div class="min-h-full pb-20 px-4">
|
<div class="min-h-full px-4 pb-10">
|
||||||
<div
|
<div
|
||||||
class="text-center mt-3"
|
class="text-center mt-3"
|
||||||
v-if="
|
v-if="
|
||||||
@@ -20,11 +20,13 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<MessagesSkeleton :count="10" v-if="conversationStore.messages.loading" />
|
||||||
|
|
||||||
<TransitionGroup
|
<TransitionGroup
|
||||||
|
v-else
|
||||||
enter-active-class="animate-slide-in"
|
enter-active-class="animate-slide-in"
|
||||||
tag="div"
|
tag="div"
|
||||||
class="space-y-4"
|
class="space-y-4"
|
||||||
v-if="!conversationStore.messages.loading"
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="message in conversationStore.conversationMessages"
|
v-for="message in conversationStore.conversationMessages"
|
||||||
@@ -43,7 +45,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TransitionGroup>
|
</TransitionGroup>
|
||||||
<MessagesSkeleton :count="20" v-else />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<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">
|
<span v-if="attachment.loading" class="dot-loader">
|
||||||
<span class="dot"></span>
|
<span class="dot"></span>
|
||||||
<span class="dot"></span>
|
<span class="dot"></span>
|
||||||
@@ -20,22 +20,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" />
|
||||||
@@ -85,13 +84,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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex items-center space-x-4 p-4">
|
<div class="flex items-center space-x-4 px-2 h-12">
|
||||||
<SidebarTrigger class="cursor-pointer w-4 h-4 text-black" />
|
<SidebarTrigger class="cursor-pointer w-4 h-4 text-black" />
|
||||||
<div class="flex-1 flex items-center">
|
<div class="flex-1 flex items-center">
|
||||||
<Search class="w-5 h-5" />
|
<Search class="w-5 h-5" />
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-4 md:block page-content">
|
<div class="overflow-y-auto h-screen">
|
||||||
<div class="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
|
<div class="p-6 sm:p-8 min-h-full flex flex-col">
|
||||||
<div class="flex-1 lg:max-w-3xl min-h-[700px]">
|
<router-view class="flex-grow" />
|
||||||
<div class="space-y-6">
|
|
||||||
<router-view />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup></script>
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-content">
|
<div class="overflow-y-auto h-screen">
|
||||||
<div class="p-6 sm:p-8">
|
<div class="p-6 sm:p-8 min-h-full flex flex-col">
|
||||||
<router-view />
|
<router-view class="flex-grow" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between h-full">
|
||||||
<div class="w-8/12">
|
<div class="w-8/12">
|
||||||
<slot name="content" />
|
<slot name="content" />
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-lg w-3/12 p-2 space-y-2 h-max-content">
|
<div class="rounded-lg w-3/12 p-2 space-y-2 self-start">
|
||||||
<slot name="help" />
|
<slot name="help" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="p-5 w-screen h-screen">
|
<div class="h-full">
|
||||||
<div class="flex flex-col space-y-5">
|
<div class="flex flex-col space-y-5">
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<span class="sub-title">Public avatar</span>
|
<span class="sub-title">Public avatar</span>
|
||||||
<p class="text-muted-foreground text-xs">Change your avatar here.</p>
|
<p class="text-muted-foreground text-xs">Change your avatar here.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex space-x-5">
|
<div class="flex space-x-5">
|
||||||
<Avatar class="size-28 bg-white">
|
<Avatar class="size-28">
|
||||||
<AvatarImage :src="userStore.avatar" alt="Cropped Image" />
|
<AvatarImage :src="userStore.avatar" alt="Cropped Image" />
|
||||||
<AvatarFallback>{{ userStore.getInitials }}</AvatarFallback>
|
<AvatarFallback>{{ userStore.getInitials }}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<AdminPageWithHelp>
|
<AdminPageWithHelp>
|
||||||
<template #content>
|
<template #content>
|
||||||
<div v-if="router.currentRoute.value.path === '/admin/automations'">
|
<div v-if="router.currentRoute.value.name === 'automations'">
|
||||||
<div class="flex justify-between mb-5">
|
<div class="flex justify-between mb-5">
|
||||||
<div class="ml-auto">
|
<div class="ml-auto">
|
||||||
<Button @click="newRule">New rule</Button>
|
<Button @click="newRule">New rule</Button>
|
||||||
@@ -34,6 +34,6 @@ import AdminPageWithHelp from '@/layouts/admin/AdminPageWithHelp.vue'
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const selectedTab = useStorage('automationsTab', 'new_conversation')
|
const selectedTab = useStorage('automationsTab', 'new_conversation')
|
||||||
const newRule = () => {
|
const newRule = () => {
|
||||||
router.push({ path: `/admin/automations/new`, query: { type: selectedTab.value } })
|
router.push({ name: 'new-automation', query: { type: selectedTab.value } })
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -235,8 +235,8 @@ const isNewForm = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const breadcrumbLinks = [
|
const breadcrumbLinks = [
|
||||||
{ path: '/admin/automations', label: 'Automations' },
|
{ path: 'automations', label: 'Automations' },
|
||||||
{ path: '#', label: breadcrumbPageLabel() }
|
{ path: '', label: breadcrumbPageLabel() }
|
||||||
]
|
]
|
||||||
|
|
||||||
const firstRuleGroup = ref([])
|
const firstRuleGroup = ref([])
|
||||||
@@ -330,7 +330,7 @@ const handleSave = async (values) => {
|
|||||||
await api.updateAutomationRule(props.id, updatedRule)
|
await api.updateAutomationRule(props.id, updatedRule)
|
||||||
} else {
|
} else {
|
||||||
await api.createAutomationRule(updatedRule)
|
await api.createAutomationRule(updatedRule)
|
||||||
router.push('/admin/automations')
|
router.push({ name: 'automations' })
|
||||||
}
|
}
|
||||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||||
title: 'Success',
|
title: 'Success',
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ const submitForm = async (values) => {
|
|||||||
title: 'Success',
|
title: 'Success',
|
||||||
description: 'Business hours created successfully',
|
description: 'Business hours created successfully',
|
||||||
})
|
})
|
||||||
router.push('/admin/business-hours')
|
router.push({ name: 'business-hours-list' })
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||||
@@ -67,8 +67,8 @@ const isNewForm = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const breadcrumbLinks = [
|
const breadcrumbLinks = [
|
||||||
{ path: '/admin/business-hours', label: 'Business hours' },
|
{ path: 'business-hours-list', label: 'Business hours' },
|
||||||
{ path: '#', label: breadCrumLabel() }
|
{ path: '', label: breadCrumLabel() }
|
||||||
]
|
]
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ const formLoading = ref(false)
|
|||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const inbox = ref({})
|
const inbox = ref({})
|
||||||
const breadcrumbLinks = [
|
const breadcrumbLinks = [
|
||||||
{ path: '/admin/inboxes', label: 'Inboxes' },
|
{ path: 'inbox-list', label: 'Inboxes' },
|
||||||
{ path: '#', label: 'Edit Inbox' }
|
{ path: '', label: 'Edit Inbox' }
|
||||||
]
|
]
|
||||||
|
|
||||||
const submitForm = (values) => {
|
const submitForm = (values) => {
|
||||||
@@ -59,7 +59,7 @@ const updateInbox = async (payload) => {
|
|||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||||
title: 'Could not update inbox',
|
title: 'Error',
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
description: handleHTTPError(error).message
|
description: handleHTTPError(error).message
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -82,8 +82,8 @@ const currentStep = ref(1)
|
|||||||
const selectedChannel = ref(null)
|
const selectedChannel = ref(null)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const breadcrumbLinks = [
|
const breadcrumbLinks = [
|
||||||
{ path: '/admin/inboxes', label: 'Inboxes' },
|
{ path: 'inbox-list', label: 'Inboxes' },
|
||||||
{ path: '#', label: 'New Inbox' }
|
{ path: '', label: 'New Inbox' }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -149,7 +149,7 @@ async function createInbox (payload) {
|
|||||||
title: 'Success',
|
title: 'Success',
|
||||||
description: 'Inbox created successfully'
|
description: 'Inbox created successfully'
|
||||||
})
|
})
|
||||||
router.push('/admin/inboxes')
|
router.push({ name: 'inbox-list' })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||||
title: 'Could not create inbox',
|
title: 'Could not create inbox',
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ const router = useRouter()
|
|||||||
const emit = useEmitter()
|
const emit = useEmitter()
|
||||||
const formLoading = ref(false)
|
const formLoading = ref(false)
|
||||||
const breadcrumbLinks = [
|
const breadcrumbLinks = [
|
||||||
{ path: '/admin/conversations/macros', label: 'Macros' },
|
{ path: 'macro-list', label: 'Macros' },
|
||||||
{ path: '#', label: 'New macro' }
|
{ path: '', label: 'New macro' }
|
||||||
]
|
]
|
||||||
|
|
||||||
const onSubmit = (values) => {
|
const onSubmit = (values) => {
|
||||||
@@ -38,7 +38,7 @@ const createMacro = async (values) => {
|
|||||||
title: 'Success',
|
title: 'Success',
|
||||||
description: 'Macro created successfully'
|
description: 'Macro created successfully'
|
||||||
})
|
})
|
||||||
router.push('/admin/conversations/macros')
|
router.push({ name: 'macro-list' })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
emit.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
emit.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ const formLoading = ref(false)
|
|||||||
const emitter = useEmitter()
|
const emitter = useEmitter()
|
||||||
|
|
||||||
const breadcrumbLinks = [
|
const breadcrumbLinks = [
|
||||||
{ path: '/admin/conversations/macros', label: 'Macros' },
|
{ path: 'macro-list', label: 'Macros' },
|
||||||
{ path: '#', label: 'Edit macro' }
|
{ path: '', label: 'Edit macro' }
|
||||||
]
|
]
|
||||||
|
|
||||||
const submitForm = (values) => {
|
const submitForm = (values) => {
|
||||||
|
|||||||
@@ -38,8 +38,23 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const submitForm = async (values) => {
|
const submitForm = async (values) => {
|
||||||
|
// Test the provider first.
|
||||||
try {
|
try {
|
||||||
formLoading.value = true
|
formLoading.value = true
|
||||||
|
await api.testOIDC({
|
||||||
|
provider_url: values.provider_url
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
formLoading.value = false
|
||||||
|
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||||
|
title: 'Error',
|
||||||
|
variant: 'destructive',
|
||||||
|
description: handleHTTPError(error).message
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
let toastDescription = ''
|
let toastDescription = ''
|
||||||
if (props.id) {
|
if (props.id) {
|
||||||
if (values.client_secret.includes('•')) {
|
if (values.client_secret.includes('•')) {
|
||||||
@@ -47,10 +62,10 @@ const submitForm = async (values) => {
|
|||||||
}
|
}
|
||||||
await api.updateOIDC(props.id, values)
|
await api.updateOIDC(props.id, values)
|
||||||
toastDescription = 'Provider updated successfully'
|
toastDescription = 'Provider updated successfully'
|
||||||
|
router.push({ name: 'sso-list' })
|
||||||
} else {
|
} else {
|
||||||
await api.createOIDC(values)
|
await api.createOIDC(values)
|
||||||
toastDescription = 'Provider created successfully'
|
toastDescription = 'Provider created successfully'
|
||||||
router.push('/admin/oidc')
|
|
||||||
}
|
}
|
||||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||||
title: 'Success',
|
title: 'Success',
|
||||||
@@ -84,8 +99,8 @@ const isNewForm = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const breadcrumbLinks = [
|
const breadcrumbLinks = [
|
||||||
{ path: '/admin/oidc', label: 'OIDC' },
|
{ path: 'sso-list', label: 'SSO' },
|
||||||
{ path: '#', label: breadCrumLabel() }
|
{ path: '', label: breadCrumLabel() }
|
||||||
]
|
]
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<AdminPageWithHelp>
|
<AdminPageWithHelp>
|
||||||
<template #content>
|
<template #content>
|
||||||
<router-view></router-view>
|
<router-view/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #help>
|
<template #help>
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
<div class="flex justify-between mb-5">
|
<div class="flex justify-between mb-5">
|
||||||
<div></div>
|
<div></div>
|
||||||
<div>
|
<div>
|
||||||
<Button @click="navigateToNewOIDC">New OIDC</Button>
|
<RouterLink :to="{ name: 'new-sso' }">
|
||||||
|
<Button>New SSO</Button>
|
||||||
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -18,7 +20,6 @@ import { ref, onMounted, onUnmounted } from 'vue'
|
|||||||
import DataTable from '@/components/datatable/DataTable.vue'
|
import DataTable from '@/components/datatable/DataTable.vue'
|
||||||
import { columns } from '@/features/admin/oidc/dataTableColumns.js'
|
import { columns } from '@/features/admin/oidc/dataTableColumns.js'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { useEmitter } from '@/composables/useEmitter'
|
import { useEmitter } from '@/composables/useEmitter'
|
||||||
|
|
||||||
import { Spinner } from '@/components/ui/spinner'
|
import { Spinner } from '@/components/ui/spinner'
|
||||||
@@ -27,7 +28,6 @@ import api from '@/api'
|
|||||||
|
|
||||||
const oidc = ref([])
|
const oidc = ref([])
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const router = useRouter()
|
|
||||||
const emit = useEmitter()
|
const emit = useEmitter()
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -52,8 +52,4 @@ const fetchAll = async () => {
|
|||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const navigateToNewOIDC = () => {
|
|
||||||
router.push('/admin/oidc/new')
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ onMounted(async () => {
|
|||||||
|
|
||||||
const breadcrumbLinks = [
|
const breadcrumbLinks = [
|
||||||
|
|
||||||
{ path: '/admin/teams/roles', label: 'Roles' },
|
{ path: 'role-list', label: 'Roles' },
|
||||||
{ path: '#', label: 'Edit role' }
|
{ path: '', label: 'Edit role' }
|
||||||
]
|
]
|
||||||
|
|
||||||
const submitForm = async (values) => {
|
const submitForm = async (values) => {
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ const emitter = useEmitter()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const formLoading = ref(false)
|
const formLoading = ref(false)
|
||||||
const breadcrumbLinks = [
|
const breadcrumbLinks = [
|
||||||
{ path: '/admin/teams/roles', label: 'Roles' },
|
{ path: 'role-list', label: 'Roles' },
|
||||||
{ path: '#', label: 'Add role' }
|
{ path: '', label: 'New role' }
|
||||||
]
|
]
|
||||||
|
|
||||||
const submitForm = async (values) => {
|
const submitForm = async (values) => {
|
||||||
@@ -31,7 +31,7 @@ const submitForm = async (values) => {
|
|||||||
title: 'Success',
|
title: 'Success',
|
||||||
description: 'Role created successfully'
|
description: 'Role created successfully'
|
||||||
})
|
})
|
||||||
router.push('/admin/teams/roles')
|
router.push({ name: 'role-list' })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||||
title: 'Could not create role',
|
title: 'Could not create role',
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
<Spinner v-if="isLoading" />
|
<Spinner v-if="isLoading" />
|
||||||
<div :class="{ 'transition-opacity duration-300 opacity-50': isLoading }">
|
<div :class="{ 'transition-opacity duration-300 opacity-50': isLoading }">
|
||||||
<div class="flex justify-end mb-5">
|
<div class="flex justify-end mb-5">
|
||||||
<Button @click="navigateToNewRole"> New role </Button>
|
<router-link :to="{ name: 'new-role' }">
|
||||||
|
<Button> New role </Button>
|
||||||
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<DataTable :columns="columns" :data="roles" />
|
<DataTable :columns="columns" :data="roles" />
|
||||||
@@ -16,14 +18,12 @@ import { columns } from '@/features/admin/roles/dataTableColumns.js'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import DataTable from '@/components/datatable/DataTable.vue'
|
import DataTable from '@/components/datatable/DataTable.vue'
|
||||||
import { handleHTTPError } from '@/utils/http'
|
import { handleHTTPError } from '@/utils/http'
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { Spinner } from '@/components/ui/spinner'
|
import { Spinner } from '@/components/ui/spinner'
|
||||||
import { useEmitter } from '@/composables/useEmitter'
|
import { useEmitter } from '@/composables/useEmitter'
|
||||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
|
|
||||||
const emitter = useEmitter()
|
const emitter = useEmitter()
|
||||||
const router = useRouter()
|
|
||||||
const roles = ref([])
|
const roles = ref([])
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
|
|
||||||
@@ -49,8 +49,4 @@ onMounted(async () => {
|
|||||||
if (data?.model === 'team') getRoles()
|
if (data?.model === 'team') getRoles()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const navigateToNewRole = () => {
|
|
||||||
router.push('/admin/teams/roles/new')
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
8
go.mod
8
go.mod
@@ -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
16
go.sum
@@ -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=
|
||||||
|
|||||||
@@ -110,6 +110,16 @@ func New(cfg Config, rd *redis.Client, logger *logf.Logger) (*Auth, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestProvider tests the OIDC provider url by doing a discovery on it.
|
||||||
|
func (a *Auth) TestProvider(url string) error {
|
||||||
|
_, err := oidc.NewProvider(context.Background(), url)
|
||||||
|
if err != nil {
|
||||||
|
a.logger.Error("error testing oidc provider", "provider_url", url, "error", err)
|
||||||
|
return envelope.NewError(envelope.GeneralError, err.Error(), nil)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Reload reloads the auth configuration.
|
// Reload reloads the auth configuration.
|
||||||
func (a *Auth) Reload(cfg Config) error {
|
func (a *Auth) Reload(cfg Config) error {
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
@@ -279,6 +289,7 @@ func (a *Auth) SetCSRFCookie(r *fastglue.Request) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidateSession validates the session and returns the user.
|
||||||
func (a *Auth) ValidateSession(r *fastglue.Request) (models.User, error) {
|
func (a *Auth) ValidateSession(r *fastglue.Request) (models.User, error) {
|
||||||
a.mu.RLock()
|
a.mu.RLock()
|
||||||
defer a.mu.RUnlock()
|
defer a.mu.RUnlock()
|
||||||
|
|||||||
@@ -196,10 +196,15 @@ func (e *Engine) assignConversations() error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user has reached the max auto assigned conversations limit.
|
teamMaxAutoAssignments := e.teamMaxAutoAssignments[conversation.AssignedTeamID.Int]
|
||||||
if activeConversationsCount >= e.teamMaxAutoAssignments[conversation.AssignedTeamID.Int] {
|
// Check if user has reached the max auto assigned conversations limit,
|
||||||
e.lo.Debug("user has reached max auto assigned conversations limit, skipping auto assignment", "user_id", userID, "user_active_conversations_count", activeConversationsCount, "max_auto_assigned_conversations", e.teamMaxAutoAssignments[conversation.AssignedTeamID.Int])
|
// If the limit is set to 0, it means there is no limit.
|
||||||
continue
|
if teamMaxAutoAssignments != 0 {
|
||||||
|
if activeConversationsCount >= teamMaxAutoAssignments {
|
||||||
|
e.lo.Debug("user has reached max auto assigned conversations limit, skipping auto assignment", "user_id", userID,
|
||||||
|
"user_active_conversations_count", activeConversationsCount, "max_auto_assigned_conversations", teamMaxAutoAssignments)
|
||||||
|
continue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Assign conversation to user.
|
// Assign conversation to user.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// package colorlog provides logging in color.
|
// package colorlog provides ANSI color logging for the terminal.
|
||||||
package colorlog
|
package colorlog
|
||||||
|
|
||||||
import "log"
|
import "log"
|
||||||
|
|||||||
@@ -399,8 +399,8 @@ func (c *Manager) ActiveUserConversationsCount(userID int) (int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UpdateConversationLastMessage updates the last message details for a conversation.
|
// UpdateConversationLastMessage updates the last message details for a conversation.
|
||||||
func (c *Manager) UpdateConversationLastMessage(convesationID int, conversationUUID, lastMessage string, lastMessageAt time.Time) error {
|
func (c *Manager) UpdateConversationLastMessage(conversation int, conversationUUID, lastMessage, lastMessageSenderType string, lastMessageAt time.Time) error {
|
||||||
if _, err := c.q.UpdateConversationLastMessage.Exec(convesationID, conversationUUID, lastMessage, lastMessageAt); err != nil {
|
if _, err := c.q.UpdateConversationLastMessage.Exec(conversation, conversationUUID, lastMessage, lastMessageSenderType, lastMessageAt); err != nil {
|
||||||
c.lo.Error("error updating conversation last message", "error", err)
|
c.lo.Error("error updating conversation last message", "error", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Run starts a pool of worker goroutines to handle message dispatching via inbox's channel and processes incoming messages. It scans for
|
// Run starts a pool of worker goroutines to handle message dispatching via inbox's channel and processes incoming messages. It scans for
|
||||||
// pending outgoing messages at the specified read interval and pushes them to the outgoing queue.
|
// pending outgoing messages at the specified read interval and pushes them to the outgoing queue to be sent.
|
||||||
func (m *Manager) Run(ctx context.Context, incomingQWorkers, outgoingQWorkers, scanInterval time.Duration) {
|
func (m *Manager) Run(ctx context.Context, incomingQWorkers, outgoingQWorkers, scanInterval time.Duration) {
|
||||||
dbScanner := time.NewTicker(scanInterval)
|
dbScanner := time.NewTicker(scanInterval)
|
||||||
defer dbScanner.Stop()
|
defer dbScanner.Stop()
|
||||||
@@ -356,10 +356,10 @@ func (m *Manager) InsertMessage(message *models.Message) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update conversation last message details in conversation metadata.
|
// Update conversation last message details in conversation metadata.
|
||||||
m.UpdateConversationLastMessage(message.ConversationID, message.ConversationUUID, message.TextContent, message.CreatedAt)
|
m.UpdateConversationLastMessage(message.ConversationID, message.ConversationUUID, message.TextContent, message.SenderType, message.CreatedAt)
|
||||||
|
|
||||||
// Broadcast new message.
|
// Broadcast new message.
|
||||||
m.BroadcastNewMessage(message.ConversationUUID, message.TextContent, message.UUID, message.CreatedAt.Format(time.RFC3339), message.Type, message.Private)
|
m.BroadcastNewMessage(message)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ type Conversation struct {
|
|||||||
CustomAttributes pq.StringArray `db:"custom_attributes" json:"custom_attributes"`
|
CustomAttributes pq.StringArray `db:"custom_attributes" json:"custom_attributes"`
|
||||||
LastMessageAt null.Time `db:"last_message_at" json:"last_message_at"`
|
LastMessageAt null.Time `db:"last_message_at" json:"last_message_at"`
|
||||||
LastMessage null.String `db:"last_message" json:"last_message"`
|
LastMessage null.String `db:"last_message" json:"last_message"`
|
||||||
|
LastMessageSender null.String `db:"last_message_sender" json:"last_message_sender"`
|
||||||
Contact umodels.User `db:"contact" json:"contact"`
|
Contact umodels.User `db:"contact" json:"contact"`
|
||||||
SLAPolicyID null.Int `db:"sla_policy_id" json:"sla_policy_id"`
|
SLAPolicyID null.Int `db:"sla_policy_id" json:"sla_policy_id"`
|
||||||
SlaPolicyName null.String `db:"sla_policy_name" json:"sla_policy_name"`
|
SlaPolicyName null.String `db:"sla_policy_name" json:"sla_policy_name"`
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ SELECT
|
|||||||
conversations.subject,
|
conversations.subject,
|
||||||
conversations.last_message,
|
conversations.last_message,
|
||||||
conversations.last_message_at,
|
conversations.last_message_at,
|
||||||
|
conversations.last_message_sender,
|
||||||
conversations.next_sla_deadline_at,
|
conversations.next_sla_deadline_at,
|
||||||
conversations.priority_id,
|
conversations.priority_id,
|
||||||
(
|
(
|
||||||
@@ -197,7 +198,7 @@ SET assignee_last_seen_at = now(),
|
|||||||
WHERE uuid = $1;
|
WHERE uuid = $1;
|
||||||
|
|
||||||
-- name: update-conversation-last-message
|
-- name: update-conversation-last-message
|
||||||
UPDATE conversations SET last_message = $3, last_message_at = $4 WHERE CASE
|
UPDATE conversations SET last_message = $3, last_message_sender = $4, last_message_at = $5, updated_at = NOW() WHERE CASE
|
||||||
WHEN $1 > 0 THEN id = $1
|
WHEN $1 > 0 THEN id = $1
|
||||||
ELSE uuid = $2
|
ELSE uuid = $2
|
||||||
END
|
END
|
||||||
|
|||||||
@@ -4,10 +4,9 @@ import "time"
|
|||||||
|
|
||||||
var DefaultStatuses = []string{
|
var DefaultStatuses = []string{
|
||||||
"Open",
|
"Open",
|
||||||
"Replied",
|
"Snoozed",
|
||||||
"Resolved",
|
"Resolved",
|
||||||
"Closed",
|
"Closed",
|
||||||
"Snoozed",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Status struct {
|
type Status struct {
|
||||||
|
|||||||
@@ -2,24 +2,26 @@ package conversation
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
|
||||||
wsmodels "github.com/abhinavxd/libredesk/internal/ws/models"
|
wsmodels "github.com/abhinavxd/libredesk/internal/ws/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BroadcastNewMessage broadcasts a new message to all users.
|
// BroadcastNewMessage broadcasts a new message to all users.
|
||||||
func (m *Manager) BroadcastNewMessage(conversationUUID, content, messageUUID, lastMessageAt, typ string, private bool) {
|
func (m *Manager) BroadcastNewMessage(message *cmodels.Message) {
|
||||||
message := wsmodels.Message{
|
m.broadcastToUsers([]int{}, wsmodels.Message{
|
||||||
Type: wsmodels.MessageTypeNewMessage,
|
Type: wsmodels.MessageTypeNewMessage,
|
||||||
Data: map[string]interface{}{
|
Data: map[string]interface{}{
|
||||||
"conversation_uuid": conversationUUID,
|
"conversation_uuid": message.ConversationUUID,
|
||||||
"content": content,
|
"content": message.TextContent,
|
||||||
"created_at": lastMessageAt,
|
"created_at": message.CreatedAt.Format(time.RFC3339),
|
||||||
"uuid": messageUUID,
|
"uuid": message.UUID,
|
||||||
"private": private,
|
"private": message.Private,
|
||||||
"type": typ,
|
"type": message.Type,
|
||||||
|
"sender_type": message.SenderType,
|
||||||
},
|
},
|
||||||
}
|
})
|
||||||
m.broadcastToUsers([]int{}, message)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// BroadcastMessageUpdate broadcasts a message update to all users.
|
// BroadcastMessageUpdate broadcasts a message update to all users.
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"log"
|
||||||
|
|
||||||
"github.com/abhinavxd/libredesk/internal/dbutil"
|
"github.com/abhinavxd/libredesk/internal/dbutil"
|
||||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||||
rmodels "github.com/abhinavxd/libredesk/internal/role/models"
|
rmodels "github.com/abhinavxd/libredesk/internal/role/models"
|
||||||
@@ -24,6 +26,14 @@ import (
|
|||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
systemUserEmail = "System"
|
||||||
|
minSystemUserPassword = 8
|
||||||
|
maxSystemUserPassword = 50
|
||||||
|
UserTypeAgent = "agent"
|
||||||
|
UserTypeContact = "contact"
|
||||||
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
//go:embed queries.sql
|
//go:embed queries.sql
|
||||||
efs embed.FS
|
efs embed.FS
|
||||||
@@ -31,14 +41,8 @@ var (
|
|||||||
// ErrPasswordTooLong is returned when the password passed to
|
// ErrPasswordTooLong is returned when the password passed to
|
||||||
// GenerateFromPassword is too long (i.e. > 72 bytes).
|
// GenerateFromPassword is too long (i.e. > 72 bytes).
|
||||||
ErrPasswordTooLong = errors.New("password length exceeds 72 bytes")
|
ErrPasswordTooLong = errors.New("password length exceeds 72 bytes")
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
SystemUserPasswordHint = fmt.Sprintf("Password must be %d-%d characters long and contain at least one uppercase letter and one number", minSystemUserPassword, maxSystemUserPassword)
|
||||||
SystemUserEmail = "System"
|
|
||||||
MinSystemUserPasswordLen = 8
|
|
||||||
MaxSystemUserPasswordLen = 50
|
|
||||||
UserTypeAgent = "agent"
|
|
||||||
UserTypeContact = "contact"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Manager handles user-related operations.
|
// Manager handles user-related operations.
|
||||||
@@ -179,7 +183,7 @@ func (u *Manager) GetByEmail(email string) (models.User, error) {
|
|||||||
|
|
||||||
// GetSystemUser retrieves the system user.
|
// GetSystemUser retrieves the system user.
|
||||||
func (u *Manager) GetSystemUser() (models.User, error) {
|
func (u *Manager) GetSystemUser() (models.User, error) {
|
||||||
return u.GetByEmail(SystemUserEmail)
|
return u.GetByEmail(systemUserEmail)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateAvatar updates the user avatar.
|
// UpdateAvatar updates the user avatar.
|
||||||
@@ -200,7 +204,7 @@ func (u *Manager) Update(id int, user models.User) error {
|
|||||||
|
|
||||||
if user.NewPassword != "" {
|
if user.NewPassword != "" {
|
||||||
if !u.isStrongPassword(user.NewPassword) {
|
if !u.isStrongPassword(user.NewPassword) {
|
||||||
return envelope.NewError(envelope.InputError, "Entered password is not strong please make sure the password has min 8, max 50 characters, at least 1 uppercase letter, 1 number", nil)
|
return envelope.NewError(envelope.InputError, SystemUserPasswordHint, nil)
|
||||||
}
|
}
|
||||||
hashedPassword, err = bcrypt.GenerateFromPassword([]byte(user.NewPassword), bcrypt.DefaultCost)
|
hashedPassword, err = bcrypt.GenerateFromPassword([]byte(user.NewPassword), bcrypt.DefaultCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -265,7 +269,7 @@ func (u *Manager) SetResetPasswordToken(id int) (string, error) {
|
|||||||
// ResetPassword sets a new password for a user.
|
// ResetPassword sets a new password for a user.
|
||||||
func (u *Manager) ResetPassword(token, password string) error {
|
func (u *Manager) ResetPassword(token, password string) error {
|
||||||
if !u.isStrongPassword(password) {
|
if !u.isStrongPassword(password) {
|
||||||
return envelope.NewError(envelope.InputError, "Entered password is not strong please make sure the password has min 8, max 50 characters, at least 1 uppercase letter, 1 number", nil)
|
return envelope.NewError(envelope.InputError, "Password is not strong enough, " + SystemUserPasswordHint, nil)
|
||||||
}
|
}
|
||||||
// Hash password.
|
// Hash password.
|
||||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
@@ -311,7 +315,7 @@ func (u *Manager) generatePassword() ([]byte, error) {
|
|||||||
|
|
||||||
// isStrongPassword checks if the password meets the required strength.
|
// isStrongPassword checks if the password meets the required strength.
|
||||||
func (u *Manager) isStrongPassword(password string) bool {
|
func (u *Manager) isStrongPassword(password string) bool {
|
||||||
if len(password) < MinSystemUserPasswordLen || len(password) > MaxSystemUserPasswordLen {
|
if len(password) < minSystemUserPassword || len(password) > maxSystemUserPassword {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
hasUppercase := regexp.MustCompile(`[A-Z]`).MatchString(password)
|
hasUppercase := regexp.MustCompile(`[A-Z]`).MatchString(password)
|
||||||
@@ -331,16 +335,29 @@ func ChangeSystemUserPassword(ctx context.Context, db *sqlx.DB) error {
|
|||||||
if err := updateSystemUserPassword(db, hashedPassword); err != nil {
|
if err := updateSystemUserPassword(db, hashedPassword); err != nil {
|
||||||
return fmt.Errorf("error updating system user password: %v", err)
|
return fmt.Errorf("error updating system user password: %v", err)
|
||||||
}
|
}
|
||||||
fmt.Println("System user password updated successfully.")
|
fmt.Println("password updated successfully.")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateSystemUser inserts a default system user into the users table with the prompted password.
|
// CreateSystemUser creates a system user with the provided password or a random one.
|
||||||
func CreateSystemUser(ctx context.Context, db *sqlx.DB) error {
|
func CreateSystemUser(ctx context.Context, password string, db *sqlx.DB) error {
|
||||||
hashedPassword, err := promptAndHashPassword(ctx)
|
var err error
|
||||||
if err != nil {
|
|
||||||
return err
|
// Set random password if not provided.
|
||||||
|
if password == "" {
|
||||||
|
password, err = stringutil.RandomAlphanumeric(32)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to generate system used password: %v", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Print("using provided password for system user")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to hash system user password: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
_, err = db.Exec(`
|
_, err = db.Exec(`
|
||||||
WITH sys_user AS (
|
WITH sys_user AS (
|
||||||
INSERT INTO users (email, type, first_name, last_name, password)
|
INSERT INTO users (email, type, first_name, last_name, password)
|
||||||
@@ -351,14 +368,24 @@ func CreateSystemUser(ctx context.Context, db *sqlx.DB) error {
|
|||||||
SELECT sys_user.id, roles.id
|
SELECT sys_user.id, roles.id
|
||||||
FROM sys_user, roles
|
FROM sys_user, roles
|
||||||
WHERE roles.name = $6`,
|
WHERE roles.name = $6`,
|
||||||
SystemUserEmail, UserTypeAgent, "System", "", hashedPassword, rmodels.RoleAdmin)
|
systemUserEmail, UserTypeAgent, "System", "", hashedPassword, rmodels.RoleAdmin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create system user: %v", err)
|
return fmt.Errorf("failed to create system user: %v", err)
|
||||||
}
|
}
|
||||||
fmt.Println("System user created successfully")
|
log.Print("system user created successfully")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsStrongSystemUserPassword checks if the password meets the required strength for system user.
|
||||||
|
func IsStrongSystemUserPassword(password string) bool {
|
||||||
|
if len(password) < minSystemUserPassword || len(password) > maxSystemUserPassword {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
hasUppercase := regexp.MustCompile(`[A-Z]`).MatchString(password)
|
||||||
|
hasNumber := regexp.MustCompile(`[0-9]`).MatchString(password)
|
||||||
|
return hasUppercase && hasNumber
|
||||||
|
}
|
||||||
|
|
||||||
// promptAndHashPassword handles password input and validation, and returns the hashed password.
|
// promptAndHashPassword handles password input and validation, and returns the hashed password.
|
||||||
func promptAndHashPassword(ctx context.Context) ([]byte, error) {
|
func promptAndHashPassword(ctx context.Context) ([]byte, error) {
|
||||||
for {
|
for {
|
||||||
@@ -366,15 +393,14 @@ func promptAndHashPassword(ctx context.Context) ([]byte, error) {
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return nil, ctx.Err()
|
return nil, ctx.Err()
|
||||||
default:
|
default:
|
||||||
fmt.Print("Please set System admin password (min 8, max 50 characters, at least 1 uppercase letter, 1 number): ")
|
fmt.Printf("Please set System user password (%s): ", SystemUserPasswordHint)
|
||||||
buffer := make([]byte, 256)
|
buffer := make([]byte, 256)
|
||||||
n, err := os.Stdin.Read(buffer)
|
n, err := os.Stdin.Read(buffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("error reading input: %v", err)
|
return nil, fmt.Errorf("error reading input: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
password := strings.TrimSpace(string(buffer[:n]))
|
password := strings.TrimSpace(string(buffer[:n]))
|
||||||
if isStrongSystemUserPassword(password) {
|
if IsStrongSystemUserPassword(password) {
|
||||||
// Hash the password using bcrypt.
|
// Hash the password using bcrypt.
|
||||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -389,19 +415,9 @@ func promptAndHashPassword(ctx context.Context) ([]byte, error) {
|
|||||||
|
|
||||||
// updateSystemUserPassword updates the password of the system user in the database.
|
// updateSystemUserPassword updates the password of the system user in the database.
|
||||||
func updateSystemUserPassword(db *sqlx.DB, hashedPassword []byte) error {
|
func updateSystemUserPassword(db *sqlx.DB, hashedPassword []byte) error {
|
||||||
_, err := db.Exec(`UPDATE users SET password = $1 WHERE email = $2`, hashedPassword, SystemUserEmail)
|
_, err := db.Exec(`UPDATE users SET password = $1 WHERE email = $2`, hashedPassword, systemUserEmail)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to update system user password: %v", err)
|
return fmt.Errorf("failed to update system user password: %v", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// isStrongSystemUserPassword checks if the password meets the required strength for system user.
|
|
||||||
func isStrongSystemUserPassword(password string) bool {
|
|
||||||
if len(password) < MinSystemUserPasswordLen || len(password) > MaxSystemUserPasswordLen {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
hasUppercase := regexp.MustCompile(`[A-Z]`).MatchString(password)
|
|
||||||
hasNumber := regexp.MustCompile(`[0-9]`).MatchString(password)
|
|
||||||
return hasUppercase && hasNumber
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -209,6 +209,7 @@ CREATE TABLE conversations (
|
|||||||
waiting_since TIMESTAMPTZ NULL,
|
waiting_since TIMESTAMPTZ NULL,
|
||||||
last_message_at TIMESTAMPTZ NULL,
|
last_message_at TIMESTAMPTZ NULL,
|
||||||
last_message TEXT NULL,
|
last_message TEXT NULL,
|
||||||
|
last_message_sender message_sender_type NULL,
|
||||||
next_sla_deadline_at TIMESTAMPTZ NULL,
|
next_sla_deadline_at TIMESTAMPTZ NULL,
|
||||||
snoozed_until TIMESTAMPTZ NULL
|
snoozed_until TIMESTAMPTZ NULL
|
||||||
);
|
);
|
||||||
@@ -512,8 +513,6 @@ INSERT INTO conversation_priorities (name) VALUES
|
|||||||
-- Default conversation statuses
|
-- Default conversation statuses
|
||||||
INSERT INTO conversation_statuses (name) VALUES
|
INSERT INTO conversation_statuses (name) VALUES
|
||||||
('Open'),
|
('Open'),
|
||||||
('In Progress'),
|
|
||||||
('Waiting'),
|
|
||||||
('Snoozed'),
|
('Snoozed'),
|
||||||
('Resolved'),
|
('Resolved'),
|
||||||
('Closed');
|
('Closed');
|
||||||
|
|||||||
Reference in New Issue
Block a user