Compare commits

...

44 Commits

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

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

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

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

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

8
.gitignore vendored
View File

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

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

18
Dockerfile Normal file
View File

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

View File

@@ -5,7 +5,7 @@ VERSION := $(shell git describe --tags)
BUILDSTR := ${VERSION} (Commit: ${LAST_COMMIT_DATE} (${LAST_COMMIT}), Build: $(shell date +"%Y-%m-%d %H:%M:%S %z")) BUILDSTR := ${VERSION} (Commit: ${LAST_COMMIT_DATE} (${LAST_COMMIT}), Build: $(shell date +"%Y-%m-%d %H:%M:%S %z"))
# Binary names and paths # Binary names and paths
BIN_LIBREDESK := libredesk.bin BIN := libredesk
FRONTEND_DIR := frontend FRONTEND_DIR := frontend
FRONTEND_DIST := ${FRONTEND_DIR}/dist FRONTEND_DIST := ${FRONTEND_DIR}/dist
STATIC := ${FRONTEND_DIST} i18n schema.sql static STATIC := ${FRONTEND_DIST} i18n schema.sql static
@@ -28,7 +28,7 @@ install-deps: $(STUFFBIN)
# Build the frontend for production. # Build the frontend for production.
.PHONY: frontend-build .PHONY: frontend-build
frontend-build: frontend-build: install-deps
@echo "→ Building frontend for production..." @echo "→ Building frontend for production..."
@cd ${FRONTEND_DIR} && pnpm build @cd ${FRONTEND_DIR} && pnpm build
@@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,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
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,15 +1,13 @@
<template> <template>
<div class="h-screen flex flex-col"> <div class="h-screen flex flex-col">
<!-- Header --> <!-- Header -->
<header class="border-b"> <div class="flex items-center space-x-4 px-2 h-12 border-b shrink-0">
<div class="flex items-center space-x-4 p-2"> <SidebarTrigger class="h-4 w-4" />
<SidebarTrigger class="h-4 w-4" /> <span class="text-xl font-semibold text-gray-800">{{ title }}</span>
<span class="text-xl font-semibold text-gray-800">{{ title }}</span> </div>
</div>
</header>
<!-- Filters --> <!-- Filters -->
<div class="bg-white px-2 py-2 flex justify-between items-center"> <div class="bg-white p-2 flex justify-between items-center">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" class="w-30"> <Button variant="ghost" class="w-30">
@@ -107,7 +105,7 @@
<!-- Loading Skeleton --> <!-- Loading Skeleton -->
<div v-if="isLoading" key="loading" class="space-y-4"> <div v-if="isLoading" key="loading" class="space-y-4">
<ConversationListItemSkeleton v-for="index in 10" :key="index" /> <ConversationListItemSkeleton v-for="index in 5" :key="index" />
</div> </div>
</TransitionGroup> </TransitionGroup>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
<template> <template>
<AdminPageWithHelp> <AdminPageWithHelp>
<template #content> <template #content>
<router-view></router-view> <router-view/>
</template> </template>
<template #help> <template #help>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -55,6 +55,7 @@ export class WebSocketClient {
const data = JSON.parse(event.data) const data = JSON.parse(event.data)
const handlers = { const handlers = {
// On new message, update the message in the conversation list and in the currently opened conversation.
[WS_EVENT.NEW_MESSAGE]: () => { [WS_EVENT.NEW_MESSAGE]: () => {
this.convStore.updateConversationList(data.data) this.convStore.updateConversationList(data.data)
this.convStore.updateConversationMessage(data.data) this.convStore.updateConversationMessage(data.data)

8
go.mod
View File

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

16
go.sum
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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