Compare commits

...

116 Commits

Author SHA1 Message Date
Abhinav Raut
36d91de8f7 fix: remove email validation from SMTP username field in email notification form schema 2025-03-06 15:11:16 +05:30
Abhinav Raut
57c1948379 fix[OOM]: fix read buffer size configuration in server settings, the readbuffer was set to the max body size making the binary go OOM. 2025-03-06 15:10:23 +05:30
Abhinav Raut
772152c40c fix: filter out empty email message ids for setting email references headers.
chore: adds debug logs.
2025-03-06 12:24:18 +05:30
Abhinav Raut
8e15d733ea fix: regression in sso login caused due to attempting in hiding client secret in the API response. Resolves #21 2025-03-05 16:13:13 +05:30
Abhinav Raut
fc47e65fcb chore: update screenshot in README 2025-03-05 04:33:11 +05:30
Abhinav Raut
760be37eda chore: update libredesk screenshot in documentation 2025-03-05 04:32:38 +05:30
Abhinav Raut
d1f08ce035 fix: handle null user last active time when marking agents offline. 2025-03-05 04:24:06 +05:30
Abhinav Raut
8551b65a27 fix: set references header in all outgoing emails, set the last 20 messages.
feat: set conversation reference number in the subject of conversation for better thread matching.
fix: hide CSAT link from conversation last message.
2025-03-05 03:49:22 +05:30
Abhinav Raut
eb499f64d0 chore: adds v0.4.0 to migration list. 2025-03-05 02:33:03 +05:30
Abhinav Raut
494bc15b0a feat: Enable agents to create conversations from the UI
Before this feature the only way to create a conversation was by adding inbox and sending an email.

Agents first search contacts by email, see a dropdown select an existing contact or fill a new email for new contact.

The backend creates contact if it does not exist, creates a conversation, sends a reply to the conversation.
Optinally assigns conversation to a user / team.

fix: Replies to emails create a new conversation instead of attaching to the previous one.

Was not happening in gmail, as gmail was sending the references headers in all replies and I missed this completely. So when libredesk searches a conversation by references headers it worked!

Instead the right way is to generate the outgoing email message id and saving it in DB. This commit fixes that.

There could be more backup strategies like putting reference number in the subject but that can be explored later.

chore: new role `conversatons:write` that enables the create conversations feature for an agent.

chore: migrations for v0.4.0.
2025-03-05 01:17:42 +05:30
Abhinav Raut
360557c58f fix: remove client_id and client_secret from get-all-oidc query 2025-03-04 22:02:42 +05:30
Abhinav Raut
8d8f08e1d2 chore add comments to command box component 2025-03-02 20:58:03 +05:30
Abhinav Raut
10b4f9d08c feat: show app version in admin tab
fix: view form validations and issues with reactivity
feat: save team inbox and view inbox dropdown state in localstorage.
fix: view inbox dropdown icon alignment.
2025-03-02 20:49:19 +05:30
Abhinav Raut
79f74363da fix: hide status dropdown in conversation list as views are prefiltered. 2025-03-02 20:44:05 +05:30
Abhinav Raut
8f6295542e fix: destroy user session when user account is disabled. 2025-03-02 19:17:42 +05:30
Abhinav Raut
8e286e2273 fix: /account navigation from sidebar. 2025-03-02 18:37:05 +05:30
Abhinav Raut
3aad69fc52 fix: update sample database credentials in config file
Matched it with default docker compose password.
2025-03-02 16:31:35 +05:30
Abhinav Raut
58825c3de9 fix: handle invalid sessions by destroying them and redirecting to login 2025-03-02 16:31:00 +05:30
Abhinav Raut
03c68afc4c fix: max age not working for cookies
Switch from expires to max age for setting cookie expiry
Set default max age to 9 hours
2025-03-02 16:28:26 +05:30
Abhinav Raut
15b9caaaed fix: prevent zap logo shrinking and ensure text wraps correctly in command bar
chore: increase command bar size.
2025-03-02 03:31:34 +05:30
Abhinav Raut
b0d3dcb5dd fix: Reply box layout for fullscreen mode 2025-03-02 03:05:51 +05:30
Abhinav Raut
96ef62b509 fix: reduce pagination sizes for conversation and message lists 2025-03-02 03:03:33 +05:30
Abhinav Raut
79c3f5a60c fix: do not clear editor state on API errors.
fix: handle macro errors silently, clear editor state on macro errors as most likely they are permission errors.
2025-03-02 03:02:46 +05:30
Abhinav Raut
70bef7b3ab fix: use explicit v-model binding to match defineModel name for action builder. 2025-03-02 02:55:20 +05:30
Abhinav Raut
b1e1dff3eb feat: replace quill editor with tiptap editor, removes the stupid hack as both editors handle new lines and empty content differently.
Quill adds <p><br></p> for new lines, while Tiptap uses <br> for Shift + Enter and <p> for Enter.

This commit fixes this hack I had added, now all editors in Libredesk are tiptap editors.

fix: Typography for agent and contact message bubbles and macro preview, as tailwind removes browser defaults. Introduces new class `native-html` for this.

fix: removes hardcoded classes in tiptap starter kit configuration as the new class `native-html` takes care of it and has to be just applied.

fix: Form validation for automations and macro form.

fix: automation list padding between items.

feat: adds bullet list and ordered list menu options to tiptap editor.
2025-03-02 01:42:17 +05:30
Abhinav Raut
9b34c2737d feat: multi-tab sync for user availability status and last activity 2025-03-01 20:33:40 +05:30
Abhinav Raut
1b63f03bb1 feat: include recipient details in email templates
With this the admin can simply add
```
Dear {{.Recipient.FirstName}},
```

To the default outgoing template and all outgoing emails will have the receipient name.
2025-03-01 20:04:49 +05:30
Abhinav Raut
26d76c966f feat: allow setting OpenAI API KEY from the UI.
feat: new `ai:manage` permission for the same
Migrations for new role.
2025-03-01 19:40:18 +05:30
Abhinav Raut
1ff335f772 fix: improve welcome email template styling and content
fix: extra large app logo in base template.
refactor: standardize template variables, explicitly pass variables for rendering into template
2025-03-01 19:10:50 +05:30
Abhinav Raut
5836ee8d90 fix: annoying scroll bar when there's a single message in a conversation
adjusts padding around single message in a conversation.
2025-02-28 22:22:13 +05:30
Abhinav Raut
98534f3c5a fix: reduce update check interval and initial sleep duration
As Libredesk is Alpha I will be pushing quick updates and fixes
2025-02-28 22:12:01 +05:30
Abhinav Raut
59951f0829 fix: private message sent as reply 2025-02-28 21:44:02 +05:30
Abhinav Raut
461ae3cf22 fix: sla badge not visible in conversation info sidebar. 2025-02-28 21:32:08 +05:30
Abhinav Raut
da5dfdbcde fix: prevent email enumeration in reset password flow. 2025-02-28 20:57:47 +05:30
Abhinav Raut
9c67c02b08 fix: ensure navigation to SSO list only after creating SSO provider and not while updating SSO provider. 2025-02-27 23:46:31 +05:30
Abhinav Raut
15b200b0db fix: add descriptions for notification settings SMTP config for better clarity 2025-02-27 23:02:14 +05:30
Abhinav Raut
f4617c599c fix: correct Zod schema for email address validation 2025-02-27 23:01:49 +05:30
Abhinav Raut
341d0b7e47 Update README.md 2025-02-27 21:37:07 +05:30
Abhinav Raut
78b8c508d8 fix: message bubble styling for better text wrapping 2025-02-27 03:01:05 +05:30
Abhinav Raut
f17d96f96f rafactor: move full screen editor and non-fullscreen editor to a common component.
feat: add typography plugin and improve DOM purifying in conversation messages
fix: sooner not working in outer app.
fix: macro actions getting deleted when macro is remove from the text editor preview.
fix: square user avatar image in sidebar,made it rounded-lg
refactor: visual fixes and improvements to macro previews for consistency with attachment preview.
2025-02-27 02:47:23 +05:30
Abhinav Raut
c75c117a4d fix: improve password handling and error reporting during password reset 2025-02-27 01:58:08 +05:30
Abhinav Raut
873d26ccb2 fix: ensure deep copy of macros, as removing macro from editor was deleting the macro action from the macro store.
- fix: conversation macro cmds visible when conversation is not open.
2025-02-26 23:19:37 +05:30
Abhinav Raut
71601364ae fix: mark conversation as read when messages are already cached 2025-02-26 17:41:25 +05:30
Abhinav Raut
44723fb70d fix: update command to start backend dev server in documentation 2025-02-26 12:11:15 +05:30
Abhinav Raut
67e1230485 feat: agent availability status
New columns in users table to store user availability status.

Websocket pings sets the last active at timestamp, once user stops sending pings (on disconnect) after 5 minutes the user availalbility status changes to offline.

Detects auto away by checking for mouse, keyboard events and sets user status to away.

User can also set their status to away manually from the sidebar.

Migrations for v0.3.0

Minor visual fixes.

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

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

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

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
VERSION export-subst

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

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

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

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

9
.gitignore vendored
View File

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

182
.goreleaser.yaml Normal file
View File

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

18
Dockerfile Normal file
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

@@ -1,11 +1,13 @@
# Build variables
LAST_COMMIT := $(shell git rev-parse --short HEAD)
LAST_COMMIT_DATE := $(shell git show -s --format=%ci ${LAST_COMMIT})
VERSION := $(shell git describe --tags)
BUILDSTR := ${VERSION} (Commit: ${LAST_COMMIT_DATE} (${LAST_COMMIT}), Build: $(shell date +"%Y-%m-%d %H:%M:%S %z"))
# Try to get the commit hash from 1) git 2) the VERSION file 3) fallback.
LAST_COMMIT := $(or $(shell git rev-parse --short HEAD 2> /dev/null),$(shell head -n 1 VERSION | grep -oP -m 1 "^[a-z0-9]+$$"), "")
# Try to get the semver from 1) git 2) the VERSION file 3) fallback.
VERSION := $(or $(LIBREDESK_VERSION),$(shell git describe --tags --abbrev=0 2> /dev/null),$(shell grep -oP 'tag: \Kv\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?' VERSION),"v0.0.0")
BUILDSTR := ${VERSION} (\#${LAST_COMMIT} $(shell date -u +"%Y-%m-%dT%H:%M:%S%z"))
# Binary names and paths
BIN_LIBREDESK := libredesk.bin
BIN := libredesk
FRONTEND_DIR := frontend
FRONTEND_DIST := ${FRONTEND_DIR}/dist
STATIC := ${FRONTEND_DIST} i18n schema.sql static
@@ -28,15 +30,15 @@ install-deps: $(STUFFBIN)
# Build the frontend for production.
.PHONY: frontend-build
frontend-build:
frontend-build: install-deps
@echo "→ Building frontend for production..."
@cd ${FRONTEND_DIR} && pnpm build
@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm build
# Run the Go backend server in development mode.
.PHONY: run-backend
run-backend:
@echo "→ Running backend..."
@go run cmd/*.go
CGO_ENABLED=0 go run -ldflags="-s -w -X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}' -X 'main.frontendDir=frontend/dist'" cmd/*.go
# Run the JS frontend server in development mode.
.PHONY: run-frontend
@@ -44,26 +46,26 @@ run-frontend:
@echo "→ Installing frontend dependencies (if not already installed)..."
@cd ${FRONTEND_DIR} && pnpm install
@echo "→ Running frontend..."
@export VUE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm dev
@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm dev
# Build the backend binary.
.PHONY: backend-build
backend-build: $(STUFFBIN)
.PHONY: build-backend
build-backend: $(STUFFBIN)
@echo "→ Building backend..."
@CGO_ENABLED=0 go build -a\
-ldflags="-X 'main.buildString=${BUILDSTR}' -X 'main.buildDate=${LAST_COMMIT_DATE}' -s -w" \
-o ${BIN_LIBREDESK} cmd/*.go
-ldflags="-X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}' -s -w" \
-o ${BIN} cmd/*.go
# Main build target: builds both frontend and backend, then stuffs static assets into the binary.
.PHONY: build
build: frontend-build backend-build stuff
build: frontend-build build-backend stuff
@echo "→ Build successful. Current version: $(VERSION)"
# Stuff static assets into the binary using stuffbin.
.PHONY: stuff
stuff: $(STUFFBIN)
@echo "→ Stuffing static assets into binary..."
@$(STUFFBIN) -a stuff -in ${BIN_LIBREDESK} -out ${BIN_LIBREDESK} ${STATIC}
@$(STUFFBIN) -a stuff -in ${BIN} -out ${BIN} ${STATIC}
# Build the application in demo mode.
.PHONY: demo-build

View File

@@ -1,39 +1,82 @@
<a href="https://zerodha.tech"><img src="https://zerodha.tech/static/images/github-badge.svg" align="right" alt="Zerodha Tech Badge" /></a>
# Libredesk
Open-source, self-hosted customer support desk. Single binary app.
Open source, self-hosted customer support desk. Single binary app.
> This project is currently in **alpha**. Features and APIs may change and are not yet fully tested.
Visit [libredesk.io](https://libredesk.io) for more info. Check out the [**Live demo**](https://demo.libredesk.io/).
## Developer Setup
![Screenshot_20250220_231723](https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-HvmxvOkalQSLp4qVezdTXaCd3dB4Rm.png)
#### Prerequisites
- **go**
- **pnpm**
- **PostgreSQL >= 13**
- **Redis**
> **CAUTION:** This project is currently in **alpha**. Features and APIs may change and are not yet fully tested.
1. **Clone the repository**:
## Features
```bash
git clone https://github.com/abhinavxd/libredesk.git
cd libredesk
```
- **Multi Inbox**
Libredesk supports multiple inboxes, letting you manage conversations across teams effortlessly.
- **Granular Permissions**
Create custom roles with granular permissions for teams and individual agents.
- **Smart Automation**
Eliminate repetitive tasks with powerful automation rules. Auto-tag, assign, and route conversations based on custom conditions.
- **CSAT Surveys**
Measure customer satisfaction with automated surveys.
- **Macros**
Save frequently sent messages as templates. With one click, send saved responses, set tags, and more.
- **Smart Organization**
Keep conversations organized with tags, custom statuses for conversations, and snoozing. Find any conversation instantly from the search bar.
- **Auto Assignment**
Distribute workload with auto assignment rules. Auto-assign conversations based on agent capacity or custom criteria.
- **SLA Management**
Set and track response time targets. Get notified when conversations are at risk of breaching SLA commitments.
- **Business Intelligence**
Connect your favorite BI tools like Metabase and create custom dashboards and reports with your support data—without lock-ins.
- **AI-Assisted Response Rewrite**
Instantly rewrite responses with AI to make them more friendly, professional, or polished.
- **Command Bar**
Opens with a simple shortcut (CTRL+k) and lets you quickly perform actions on conversations.
2. **Configure the Application**:
And more checkout - [libredesk.io](https://libredesk.io)
- Copy the sample configuration file `config.toml.sample` to `config.toml`:
```bash
cp config.toml.sample config.toml
```
- Edit the `config.toml` file to configure your database and Redis connection settings.
3. **Run in Development Mode**:
## Installation
- Backend: `make run-backend`
- Frontend: `make run-frontend`
### Docker
---
The latest image is available on DockerHub at [`libredesk/libredesk:latest`](https://hub.docker.com/r/libredesk/libredesk/tags?page=1&ordering=last_updated&name=latest)
Visit [libredesk.io](https://libredesk.io) for more info.
```shell
# Download the compose file and sample config file in the current directory.
curl -LO https://github.com/abhinavxd/libredesk/raw/main/docker-compose.yml
curl -LO https://github.com/abhinavxd/libredesk/raw/main/config.sample.toml
# Copy the config.sample.toml to config.toml and edit it as needed.
cp config.sample.toml config.toml
# Run the services in the background.
docker compose up -d
# Setting System user password.
docker exec -it libredesk_app ./libredesk --set-system-user-password
```
Go to `http://localhost:9000` and login with username `System` and the password you set using the --set-system-user-password command.
See [installation docs](https://libredesk.io/docs/installation/)
__________________
### Binary
- Download the [latest release](https://github.com/abhinavxd/libredesk/releases) and extract the libredesk binary.
- Copy config.sample.toml to config.toml and edit as needed.
- `./libredesk --install` to setup the Postgres DB (or `--upgrade` to upgrade an existing DB. Upgrades are idempotent and running them multiple times have no side effects).
- Run `./libredesk --set-system-user-password` to set the password for the System user.
- Run `./libredesk` and visit `http://localhost:9000` and login with username `System` and the password you set using the --set-system-user-password command.
See [installation docs](https://libredesk.io/docs/installation)
__________________
## Developers
If you are interested in contributing, refer to the [developer setup](https://libredesk.io/docs/developer-setup/). The backend is written in Go and the frontend is Vue js 3 with Shadcn for UI components.

2
VERSION Normal file
View File

@@ -0,0 +1,2 @@
$Format:%h$
$Format:%D$

View File

@@ -1,6 +1,14 @@
package main
import "github.com/zerodha/fastglue"
import (
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/zerodha/fastglue"
)
type providerUpdateReq struct {
Provider string `json:"provider"`
APIKey string `json:"api_key"`
}
// handleAICompletion handles AI completion requests
func handleAICompletion(r *fastglue.Request) error {
@@ -27,3 +35,18 @@ func handleGetAIPrompts(r *fastglue.Request) error {
}
return r.SendEnvelope(resp)
}
// handleUpdateAIProvider updates the AI provider
func handleUpdateAIProvider(r *fastglue.Request) error {
var (
app = r.Context.(*App)
req providerUpdateReq
)
if err := r.Decode(&req, "json"); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, "Error unmarshalling request", nil))
}
if err := app.ai.UpdateProvider(req.Provider, req.APIKey); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("Provider updated successfully")
}

View File

@@ -75,7 +75,7 @@ func handleOIDCCallback(r *fastglue.Request) error {
}
// Lookup the user by email and set the session.
user, err := app.user.GetByEmail(claims.Email)
user, err := app.user.GetAgentByEmail(claims.Email)
if err != nil {
return sendErrorEnvelope(r, err)
}

View File

@@ -3,6 +3,7 @@ package main
import (
"encoding/json"
"strconv"
"strings"
"time"
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
@@ -42,7 +43,6 @@ func handleGetAllConversations(r *fastglue.Request) error {
if conversations[i].SLAPolicyID.Int != 0 {
setSLADeadlines(app, &conversations[i])
}
conversations[i].ID = 0
}
return r.SendEnvelope(envelope.PageResults{
@@ -79,7 +79,6 @@ func handleGetAssignedConversations(r *fastglue.Request) error {
if conversations[i].SLAPolicyID.Int != 0 {
setSLADeadlines(app, &conversations[i])
}
conversations[i].ID = 0
}
return r.SendEnvelope(envelope.PageResults{
@@ -116,7 +115,6 @@ func handleGetUnassignedConversations(r *fastglue.Request) error {
if conversations[i].SLAPolicyID.Int != 0 {
setSLADeadlines(app, &conversations[i])
}
conversations[i].ID = 0
}
return r.SendEnvelope(envelope.PageResults{
@@ -153,7 +151,7 @@ func handleGetViewConversations(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusForbidden, "You don't have access to this view.", nil, envelope.PermissionError)
}
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -195,7 +193,6 @@ func handleGetViewConversations(r *fastglue.Request) error {
if conversations[i].SLAPolicyID.Int != 0 {
setSLADeadlines(app, &conversations[i])
}
conversations[i].ID = 0
}
return r.SendEnvelope(envelope.PageResults{
@@ -248,7 +245,6 @@ func handleGetTeamUnassignedConversations(r *fastglue.Request) error {
if conversations[i].SLAPolicyID.Int != 0 {
setSLADeadlines(app, &conversations[i])
}
conversations[i].ID = 0
}
return r.SendEnvelope(envelope.PageResults{
@@ -268,7 +264,7 @@ func handleGetConversation(r *fastglue.Request) error {
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -284,7 +280,6 @@ func handleGetConversation(r *fastglue.Request) error {
prev, _ := app.conversation.GetContactConversations(conv.ContactID)
conv.PreviousConversations = filterCurrentConv(prev, conv.UUID)
conv.ID = 0
return r.SendEnvelope(conv)
}
@@ -295,7 +290,7 @@ func handleUpdateConversationAssigneeLastSeen(r *fastglue.Request) error {
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -316,7 +311,7 @@ func handleGetConversationParticipants(r *fastglue.Request) error {
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -343,7 +338,7 @@ func handleUpdateUserAssignee(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `assignee_id`", nil, envelope.InputError)
}
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -375,7 +370,7 @@ func handleUpdateTeamAssignee(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid assignee `id`.", nil, envelope.InputError)
}
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -426,7 +421,7 @@ func handleUpdateConversationPriority(r *fastglue.Request) error {
if err != nil {
return sendErrorEnvelope(r, err)
}
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -471,7 +466,7 @@ func handleUpdateConversationStatus(r *fastglue.Request) error {
}
// Enforce conversation access.
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -528,7 +523,7 @@ func handleUpdateConversationtags(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -607,7 +602,7 @@ func handleRemoveUserAssignee(r *fastglue.Request) error {
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -628,7 +623,7 @@ func handleRemoveTeamAssignee(r *fastglue.Request) error {
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -651,3 +646,99 @@ func filterCurrentConv(convs []cmodels.Conversation, uuid string) []cmodels.Conv
}
return []cmodels.Conversation{}
}
// handleCreateConversation creates a new conversation and sends a message to it.
func handleCreateConversation(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
inboxID = r.RequestCtx.PostArgs().GetUintOrZero("inbox_id")
assignedAgentID = r.RequestCtx.PostArgs().GetUintOrZero("agent_id")
assignedTeamID = r.RequestCtx.PostArgs().GetUintOrZero("team_id")
email = strings.TrimSpace(string(r.RequestCtx.PostArgs().Peek("contact_email")))
firstName = strings.TrimSpace(string(r.RequestCtx.PostArgs().Peek("first_name")))
lastName = strings.TrimSpace(string(r.RequestCtx.PostArgs().Peek("last_name")))
subject = strings.TrimSpace(string(r.RequestCtx.PostArgs().Peek("subject")))
content = string(r.RequestCtx.PostArgs().Peek("content"))
)
// Validate required fields
if inboxID <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "inbox_id is required", nil, envelope.InputError)
}
if subject == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "subject is required", nil, envelope.InputError)
}
if content == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "content is required", nil, envelope.InputError)
}
if email == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Contact email is required", nil, envelope.InputError)
}
if firstName == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "First name is required when creating a new contact", nil, envelope.InputError)
}
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Check if inbox exists and is enabled.
inbox, err := app.inbox.GetDBRecord(inboxID)
if err != nil {
return sendErrorEnvelope(r, err)
}
if !inbox.Enabled {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "The chosen inbox is disabled", nil, envelope.InputError)
}
// Find or create contact.
contact := umodels.User{
Email: null.StringFrom(email),
SourceChannelID: null.StringFrom(email),
FirstName: firstName,
LastName: lastName,
InboxID: inboxID,
}
if err := app.user.CreateContact(&contact); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, "Error creating contact", nil))
}
// Create conversation
conversationID, conversationUUID, err := app.conversation.CreateConversation(
contact.ID,
contact.ContactChannelID,
inboxID,
"", /** last_message **/
time.Now(),
subject,
true, /** append reference number to subject **/
)
if err != nil {
app.lo.Error("error creating conversation", "error", err)
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, "Error creating conversation", nil))
}
// Send reply to the created conversation.
if err := app.conversation.SendReply(nil /**media**/, inboxID, auser.ID, conversationUUID, content, nil /**cc**/, nil /**bcc**/, map[string]any{} /**meta**/); err != nil {
if err := app.conversation.DeleteConversation(conversationUUID); err != nil {
app.lo.Error("error deleting conversation", "error", err)
}
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, "Error sending message", nil))
}
// Assign the conversation to the agent or team.
if assignedAgentID > 0 {
app.conversation.UpdateConversationUserAssignee(conversationUUID, assignedAgentID, user)
}
if assignedTeamID > 0 {
app.conversation.UpdateConversationTeamAssignee(conversationUUID, assignedTeamID, user)
}
// Send the created conversation back to the client.
conversation, err := app.conversation.GetConversation(conversationID, "")
if err != nil {
app.lo.Error("error fetching created conversation", "error", err)
}
return r.SendEnvelope(conversation)
}

View File

@@ -37,8 +37,9 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
// OpenID connect single sign-on.
g.GET("/api/v1/oidc/enabled", handleGetAllEnabledOIDC)
g.GET("/api/v1/oidc", perm(handleGetAllOIDC, "oidc:manage"))
g.GET("/api/v1/oidc/{id}", perm(handleGetOIDC, "oidc:manage"))
g.POST("/api/v1/oidc", perm(handleCreateOIDC, "oidc:manage"))
g.POST("/api/v1/oidc/test", perm(handleTestOIDC, "oidc:manage"))
g.GET("/api/v1/oidc/{id}", perm(handleGetOIDC, "oidc:manage"))
g.PUT("/api/v1/oidc/{id}", perm(handleUpdateOIDC, "oidc:manage"))
g.DELETE("/api/v1/oidc/{id}", perm(handleDeleteOIDC, "oidc:manage"))
@@ -62,10 +63,12 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.GET("/api/v1/conversations/{uuid}/messages", perm(handleGetMessages, "messages:read"))
g.POST("/api/v1/conversations/{cuuid}/messages", perm(handleSendMessage, "messages:write"))
g.PUT("/api/v1/conversations/{cuuid}/messages/{uuid}/retry", perm(handleRetryMessage, "messages:write"))
g.POST("/api/v1/conversations", perm(handleCreateConversation, "conversations:write"))
// Search.
g.GET("/api/v1/conversations/search", perm(handleSearchConversations, "conversations:read"))
g.GET("/api/v1/messages/search", perm(handleSearchMessages, "messages:read"))
g.GET("/api/v1/contacts/search", perm(handleSearchContacts, "conversations:write"))
// Views.
g.GET("/api/v1/views/me", perm(handleGetUserViews, "view:manage"))
@@ -98,6 +101,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.GET("/api/v1/users/me", auth(handleGetCurrentUser))
g.PUT("/api/v1/users/me", auth(handleUpdateCurrentUser))
g.GET("/api/v1/users/me/teams", auth(handleGetCurrentUserTeams))
g.PUT("/api/v1/users/me/availability", auth(handleUpdateUserAvailability))
g.DELETE("/api/v1/users/me/avatar", auth(handleDeleteAvatar))
g.GET("/api/v1/users/compact", auth(handleGetUsersCompact))
g.GET("/api/v1/users", perm(handleGetUsers, "users:manage"))
@@ -172,6 +176,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
// AI completion.
g.GET("/api/v1/ai/prompts", auth(handleGetAIPrompts))
g.POST("/api/v1/ai/completion", auth(handleAICompletion))
g.PUT("/api/v1/ai/provider", perm(handleUpdateAIProvider, "ai:manage"))
// WebSocket.
g.GET("/ws", auth(func(r *fastglue.Request) error {

View File

@@ -98,6 +98,9 @@ func initFlags() {
"path to one or more config files (will be merged in order)")
f.Bool("version", false, "show current version of the build")
f.Bool("install", false, "setup database")
f.Bool("idempotent-install", false, "run idempotent installation, i.e., skip installion if schema is already installed useful for the first time setup")
f.Bool("yes", false, "skip confirmation prompt")
f.Bool("upgrade", false, "upgrade the database schema")
f.Bool("set-system-user-password", false, "set password for the system user")
if err := f.Parse(os.Args[1:]); err != nil {
@@ -305,6 +308,11 @@ func initCSAT(db *sqlx.DB) *csat.Manager {
return m
}
// initWS inits websocket hub.
func initWS(user *user.Manager) *ws.Hub {
return ws.NewHub(user)
}
// initTemplates inits template manager.
func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts *constants) *tmpl.Manager {
var (
@@ -546,7 +554,7 @@ func initEmailInbox(inboxRecord imodels.Inbox, store inbox.MessageStore) (inbox.
return nil, fmt.Errorf("initializing `%s` inbox: `%s` error : %w", inboxRecord.Channel, inboxRecord.Name, err)
}
log.Printf("`%s` inbox successfully initialized. %d SMTP servers. %d IMAP clients.", inboxRecord.Name, len(config.SMTP), len(config.IMAP))
log.Printf("`%s` inbox successfully initialized", inboxRecord.Name)
return inbox, nil
}

View File

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

View File

@@ -3,6 +3,7 @@ package main
import (
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
"github.com/abhinavxd/libredesk/internal/envelope"
umodels "github.com/abhinavxd/libredesk/internal/user/models"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
@@ -11,14 +12,24 @@ import (
func handleLogin(r *fastglue.Request) error {
var (
app = r.Context.(*App)
p = r.RequestCtx.PostArgs()
email = string(p.Peek("email"))
password = p.Peek("password")
email = string(r.RequestCtx.PostArgs().Peek("email"))
password = r.RequestCtx.PostArgs().Peek("password")
)
user, err := app.user.VerifyPassword(email, password)
if err != nil {
return sendErrorEnvelope(r, err)
}
if !user.Enabled {
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, "Your account is disabled, please contact administrator", nil))
}
// Set user availability status to online.
if err := app.user.UpdateAvailability(user.ID, umodels.Online); err != nil {
return sendErrorEnvelope(r, err)
}
user.AvailabilityStatus = umodels.Online
if err := app.auth.SaveSession(amodels.User{
ID: user.ID,
Email: user.Email.String,

View File

@@ -145,7 +145,7 @@ func handleApplyMacro(r *fastglue.Request) error {
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
incomingActions = []autoModels.RuleAction{}
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -239,7 +239,7 @@ func setDisplayValues(app *App, actions []autoModels.RuleAction) error {
return t.Name, nil
},
autoModels.ActionAssignUser: func(id int) (string, error) {
u, err := app.user.Get(id)
u, err := app.user.GetAgent(id)
if err != nil {
app.lo.Warn("user not found for macro action", "user_id", id)
return "", err

View File

@@ -6,8 +6,10 @@ import (
"log"
"os"
"os/signal"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/abhinavxd/libredesk/internal/ai"
auth_ "github.com/abhinavxd/libredesk/internal/auth"
@@ -34,7 +36,6 @@ import (
"github.com/abhinavxd/libredesk/internal/team"
"github.com/abhinavxd/libredesk/internal/template"
"github.com/abhinavxd/libredesk/internal/user"
"github.com/abhinavxd/libredesk/internal/ws"
"github.com/knadh/go-i18n"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
@@ -50,7 +51,8 @@ var (
frontendDir = "frontend/dist"
// Injected at build time.
buildString = ""
buildString string
versionString string
)
// App is the global app context which is passed and injected in the http handlers.
@@ -82,6 +84,10 @@ type App struct {
ai *ai.Manager
search *search.Manager
notifier *notifier.Service
// Global state that stores data on an available app update.
update *AppUpdate
sync.Mutex
}
func main() {
@@ -99,9 +105,8 @@ func main() {
}
// Build string injected at build time.
if buildString != "" {
colorlog.Green("Build: %s", buildString)
}
colorlog.Green("Build: %s", buildString)
colorlog.Green("Version: %s", versionString)
// Load the config files into Koanf.
initConfig(ko)
@@ -114,7 +119,7 @@ func main() {
// Installer.
if ko.Bool("install") {
install(ctx, db, fs)
install(ctx, db, fs, ko.Bool("idempotent-install"), !ko.Bool("yes"))
os.Exit(0)
}
@@ -130,10 +135,19 @@ func main() {
log.Fatalf("error checking db schema: %v", err)
}
if !installed {
log.Println("Database tables are missing. Use the `--install` flag to set up the database schema.")
log.Println("database tables are missing. Use the `--install` flag to set up the database schema.")
os.Exit(0)
}
// Upgrade.
if ko.Bool("upgrade") {
upgrade(db, fs, !ko.Bool("yes"))
os.Exit(0)
}
// Check for pending upgrade.
checkPendingUpgrade(db)
// Load app settings from DB into the Koanf instance.
settings := initSettings(db)
loadSettings(settings)
@@ -147,7 +161,6 @@ func main() {
messageOutgoingScanInterval = ko.MustDuration("message.message_outoing_scan_interval")
slaEvaluationInterval = ko.MustDuration("sla.evaluation_interval")
lo = initLogger(appName)
wsHub = ws.NewHub()
rdb = initRedis()
constants = initConstants()
i18n = initI18n(fs)
@@ -162,6 +175,7 @@ func main() {
team = initTeam(db)
businessHours = initBusinessHours(db)
user = initUser(i18n, db)
wsHub = initWS(user)
notifier = initNotifier(user)
automation = initAutomationEngine(db)
sla = initSLA(db, team, settings, businessHours)
@@ -178,6 +192,7 @@ func main() {
go notifier.Run(ctx)
go sla.Run(ctx, slaEvaluationInterval)
go media.DeleteUnlinkedMedia(ctx)
go user.MonitorAgentAvailability(ctx)
var app = &App{
lo: lo,
@@ -220,7 +235,7 @@ func main() {
WriteTimeout: ko.MustDuration("app.server.write_timeout"),
MaxRequestBodySize: ko.MustInt("app.server.max_body_size"),
MaxKeepaliveDuration: ko.MustDuration("app.server.keepalive_timeout"),
ReadBufferSize: ko.MustInt("app.server.max_body_size"),
ReadBufferSize: ko.Int("app.server.read_buffer_size"),
}
go func() {
@@ -233,6 +248,11 @@ func main() {
}
}()
// Start the app update checker.
if ko.Bool("app.check_updates") {
go checkUpdates(versionString, time.Hour*1, app)
}
// Wait for shutdown signal.
<-ctx.Done()
colorlog.Red("Shutting down HTTP server...")

View File

@@ -150,7 +150,7 @@ func handleServeMedia(r *fastglue.Request) error {
uuid = r.RequestCtx.UserValue("uuid").(string)
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}

View File

@@ -30,7 +30,7 @@ func handleGetMessages(r *fastglue.Request) error {
total = 0
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -70,7 +70,7 @@ func handleGetMessage(r *fastglue.Request) error {
cuuid = r.RequestCtx.UserValue("cuuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -105,7 +105,7 @@ func handleRetryMessage(r *fastglue.Request) error {
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -133,13 +133,13 @@ func handleSendMessage(r *fastglue.Request) error {
req = messageReq{}
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Check permission
_, err = enforceConversationAccess(app, cuuid, user)
conv, err := enforceConversationAccess(app, cuuid, user)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -163,7 +163,7 @@ func handleSendMessage(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
} else {
if err := app.conversation.SendReply(media, user.ID, cuuid, req.Message, req.CC, req.BCC, map[string]interface{}{}); err != nil {
if err := app.conversation.SendReply(media, conv.InboxID, user.ID, cuuid, req.Message, req.CC, req.BCC, map[string]any{} /**meta**/); err != nil {
return sendErrorEnvelope(r, err)
}
// Evaluate automation rules.

View File

@@ -8,6 +8,7 @@ import (
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
"github.com/zerodha/simplesessions/v3"
)
// tryAuth is a middleware that attempts to authenticate the user and add them to the context
@@ -23,7 +24,7 @@ func tryAuth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
}
// Try to get user.
user, err := app.user.Get(userSession.ID)
user, err := app.user.GetAgent(userSession.ID)
if err != nil {
return handler(r)
}
@@ -43,9 +44,7 @@ func tryAuth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
// auth makes sure the user is logged in.
func auth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
return func(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
var app = r.Context.(*App)
// Validate session and fetch user.
userSession, err := app.auth.ValidateSession(r)
@@ -55,7 +54,7 @@ func auth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
}
// Set user in the request context.
user, err := app.user.Get(userSession.ID)
user, err := app.user.GetAgent(userSession.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -92,11 +91,19 @@ func perm(handler fastglue.FastRequestHandler, perm string) fastglue.FastRequest
}
// Get user from DB.
user, err := app.user.Get(sessUser.ID)
user, err := app.user.GetAgent(sessUser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Destroy session if user is disabled.
if !user.Enabled {
if err := app.auth.DestroySession(r); err != nil {
app.lo.Error("error destroying session", "error", err)
}
return r.SendErrorEnvelope(http.StatusUnauthorized, "User account disabled", nil, envelope.PermissionError)
}
// Split the permission string into object and action and enforce it.
parts := strings.Split(perm, ":")
if len(parts) != 2 {
@@ -131,9 +138,17 @@ func authPage(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
// Validate session.
user, err := app.auth.ValidateSession(r)
if err != nil {
app.lo.Error("error validating session", "error", err)
return r.SendErrorEnvelope(http.StatusUnauthorized, "Invalid or expired session", nil, envelope.PermissionError)
// Session is not valid, destroy it and redirect to login.
if err != simplesessions.ErrInvalidSession {
app.lo.Error("error validating session", "error", err)
return r.SendErrorEnvelope(http.StatusUnauthorized, "Error validating session", nil, envelope.PermissionError)
}
if err := app.auth.DestroySession(r); err != nil {
app.lo.Error("error destroying session", "error", err)
}
}
// User is authenticated.
if user.ID > 0 {
return handler(r)
}
@@ -142,7 +157,7 @@ func authPage(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
if len(nextURI) == 0 {
nextURI = r.RequestCtx.RequestURI()
}
return r.RedirectURI("/", fasthttp.StatusFound, map[string]interface{}{
return r.RedirectURI("/", fasthttp.StatusFound, map[string]any{
"next": string(nextURI),
}, "")
}

View File

@@ -2,9 +2,11 @@ package main
import (
"strconv"
"strings"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/oidc/models"
"github.com/abhinavxd/libredesk/internal/stringutil"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
@@ -26,6 +28,10 @@ func handleGetAllOIDC(r *fastglue.Request) error {
if err != nil {
return sendErrorEnvelope(r, err)
}
// Replace secrets with dummy values.
for i := range out {
out[i].ClientSecret = strings.Repeat(stringutil.PasswordDummy, 10)
}
return r.SendEnvelope(out)
}
@@ -44,6 +50,19 @@ func handleGetOIDC(r *fastglue.Request) error {
return r.SendEnvelope(o)
}
// handleTestOIDC tests an OIDC provider URL by doing a discovery on the provider URL.
func handleTestOIDC(r *fastglue.Request) error {
var (
app = r.Context.(*App)
providerURL = string(r.RequestCtx.PostArgs().Peek("provider_url"))
)
if err := app.auth.TestProvider(providerURL); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("OIDC provider discovered successfully")
}
// handleCreateOIDC creates a new OIDC record.
func handleCreateOIDC(r *fastglue.Request) error {
var (
app = r.Context.(*App)
@@ -52,18 +71,19 @@ func handleCreateOIDC(r *fastglue.Request) error {
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Bad request", nil, envelope.GeneralError)
}
err := app.oidc.Create(req)
if err != nil {
if err := app.oidc.Create(req); err != nil {
return sendErrorEnvelope(r, err)
}
// Reload the auth manager to update the OIDC providers.
if err := reloadAuth(app); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error reloading auth", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, envelope.GeneralError)
}
return r.SendEnvelope("OIDC created successfully")
}
// handleUpdateOIDC updates an OIDC record.
func handleUpdateOIDC(r *fastglue.Request) error {
var (
app = r.Context.(*App)
@@ -79,8 +99,7 @@ func handleUpdateOIDC(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Bad request", nil, envelope.GeneralError)
}
err = app.oidc.Update(id, req)
if err != nil {
if err = app.oidc.Update(id, req); err != nil {
return sendErrorEnvelope(r, err)
}
@@ -91,23 +110,16 @@ func handleUpdateOIDC(r *fastglue.Request) error {
return r.SendEnvelope("OIDC updated successfully")
}
// handleDeleteOIDC deletes an OIDC record.
func handleDeleteOIDC(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
var app = r.Context.(*App)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid oidc `id`.", nil, envelope.InputError)
}
err = app.oidc.Delete(id)
if err != nil {
if err = app.oidc.Delete(id); err != nil {
return sendErrorEnvelope(r, err)
}
// Reload the auth manager to update the OIDC providers.
if err := reloadAuth(app); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, envelope.GeneralError)
}
return r.SendEnvelope("OIDC deleted successfully")
}

View File

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

View File

@@ -44,3 +44,19 @@ func handleSearchMessages(r *fastglue.Request) error {
}
return r.SendEnvelope(messages)
}
// handleSearchContacts searches contacts based on the query.
func handleSearchContacts(r *fastglue.Request) error {
var (
app = r.Context.(*App)
q = string(r.RequestCtx.QueryArgs().Peek("query"))
)
if len(q) < minSearchQueryLength {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, "Query length should be at least 3 characters", nil))
}
contacts, err := app.search.Contacts(q)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(contacts)
}

View File

@@ -2,6 +2,7 @@ package main
import (
"encoding/json"
"net/mail"
"strings"
"github.com/abhinavxd/libredesk/internal/envelope"
@@ -20,7 +21,17 @@ func handleGetGeneralSettings(r *fastglue.Request) error {
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(out)
// Unmarshal to set the app.update to the settings, so the frontend can show that an update is available.
var settings map[string]interface{}
if err := json.Unmarshal(out, &settings); err != nil {
app.lo.Error("error unmarshalling settings", "err", err)
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, "Error fetching settings", nil))
}
// Set the app.update to the settings, adding `app` prefix to the key to match the settings structure in db.
settings["app.update"] = app.update
// Set app version.
settings["app.version"] = versionString
return r.SendEnvelope(settings)
}
// handleUpdateGeneralSettings updates general settings.
@@ -90,6 +101,11 @@ func handleUpdateEmailNotificationSettings(r *fastglue.Request) error {
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, "Error updating settings", nil))
}
// Make sure it's a valid from email address.
if _, err := mail.ParseAddress(req.EmailAddress); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid from email address format", nil, envelope.InputError)
}
if req.Password == "" {
req.Password = cur.Password
}
@@ -97,5 +113,7 @@ func handleUpdateEmailNotificationSettings(r *fastglue.Request) error {
if err := app.setting.Update(req); err != nil {
return sendErrorEnvelope(r, err)
}
// No reload implemented, so user has to restart the app.
return r.SendEnvelope("Settings updated successfully, Please restart the app for changes to take effect.")
}

98
cmd/updates.go Normal file
View File

@@ -0,0 +1,98 @@
// Copyright Kailash Nadh (https://github.com/knadh/listmonk)
// SPDX-License-Identifier: AGPL-3.0
// Adapted from listmonk for Libredesk.
package main
import (
"encoding/json"
"io"
"net/http"
"regexp"
"time"
"golang.org/x/mod/semver"
)
const updateCheckURL = "https://updates.libredesk.io/updates.json"
type AppUpdate struct {
Update struct {
ReleaseVersion string `json:"release_version"`
ReleaseDate string `json:"release_date"`
URL string `json:"url"`
Description string `json:"description"`
// This is computed and set locally based on the local version.
IsNew bool `json:"is_new"`
} `json:"update"`
Messages []struct {
Date string `json:"date"`
Title string `json:"title"`
Description string `json:"description"`
URL string `json:"url"`
Priority string `json:"priority"`
} `json:"messages"`
}
var reSemver = regexp.MustCompile(`-(.*)`)
// checkUpdates is a blocking function that checks for updates to the app
// at the given intervals. On detecting a new update (new semver), it
// sets the global update status that renders a prompt on the UI.
func checkUpdates(curVersion string, interval time.Duration, app *App) {
// Strip -* suffix.
curVersion = reSemver.ReplaceAllString(curVersion, "")
fnCheck := func() {
resp, err := http.Get(updateCheckURL)
if err != nil {
app.lo.Error("error checking for app updates", "err", err)
return
}
if resp.StatusCode != 200 {
app.lo.Error("non-ok status code checking for app updates", "status", resp.StatusCode)
return
}
b, err := io.ReadAll(resp.Body)
if err != nil {
app.lo.Error("error reading response body", "err", err)
return
}
resp.Body.Close()
var out AppUpdate
if err := json.Unmarshal(b, &out); err != nil {
app.lo.Error("error unmarshalling response body", "err", err)
return
}
// There is an update. Set it on the global app state.
if semver.IsValid(out.Update.ReleaseVersion) {
v := reSemver.ReplaceAllString(out.Update.ReleaseVersion, "")
if semver.Compare(v, curVersion) > 0 {
out.Update.IsNew = true
app.lo.Info("new update available", "version", out.Update.ReleaseVersion)
}
}
app.Lock()
app.update = &out
app.Unlock()
}
// Give a 5 minute buffer after app start in case the admin wants to disable
// update checks entirely and not make a request to upstream.
time.Sleep(time.Minute * 5)
fnCheck()
// Thereafter, check every $interval.
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
fnCheck()
}
}

149
cmd/upgrade.go Normal file
View File

@@ -0,0 +1,149 @@
// Copyright Kailash Nadh (https://github.com/knadh/listmonk)
// SPDX-License-Identifier: AGPL-3.0
// Adapted from listmonk for Libredesk.
package main
import (
"fmt"
"log"
"strings"
"github.com/abhinavxd/libredesk/internal/dbutil"
"github.com/abhinavxd/libredesk/internal/migrations"
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
"golang.org/x/mod/semver"
)
// migFunc represents a migration function for a particular version.
// fn (generally) executes database migrations and additionally
// takes the filesystem and config objects in case there are additional bits
// of logic to be performed before executing upgrades. fn is idempotent.
type migFunc struct {
version string
fn func(*sqlx.DB, stuffbin.FileSystem, *koanf.Koanf) error
}
// migList is the list of available migList ordered by the semver.
// Each migration is a Go file in internal/migrations named after the semver.
// The functions are named as: v0.7.0 => migrations.V0_7_0() and are idempotent.
var migList = []migFunc{
{"v0.3.0", migrations.V0_3_0},
{"v0.4.0", migrations.V0_4_0},
}
// upgrade upgrades the database to the current version by running SQL migration files
// for all version from the last known version to the current one.
func upgrade(db *sqlx.DB, fs stuffbin.FileSystem, prompt bool) {
if prompt {
var ok string
fmt.Printf("** IMPORTANT: Take a backup of the database before upgrading.\n")
fmt.Print("continue (y/n)? ")
if _, err := fmt.Scanf("%s", &ok); err != nil {
log.Fatalf("error reading value from terminal: %v", err)
}
if !strings.EqualFold(ok, "y") {
fmt.Println("upgrade cancelled")
return
}
}
_, toRun, err := getPendingMigrations(db)
if err != nil {
log.Fatalf("error checking migrations: %v", err)
}
// No migrations to run.
if len(toRun) == 0 {
log.Printf("no upgrades to run. Database is up to date.")
return
}
// Execute migrations in succession.
for _, m := range toRun {
log.Printf("running migration %s", m.version)
if err := m.fn(db, fs, ko); err != nil {
log.Fatalf("error running migration %s: %v", m.version, err)
}
// Record the migration version in the settings table. There was no
// settings table until v0.7.0, so ignore the no-table errors.
if err := recordMigrationVersion(m.version, db); err != nil {
if dbutil.IsTableNotExistError(err) {
continue
}
log.Fatalf("error recording migration version %s: %v", m.version, err)
}
}
log.Printf("upgrade complete")
}
// getPendingMigrations gets the pending migrations by comparing the last
// recorded migration in the DB against all migrations listed in `migrations`.
func getPendingMigrations(db *sqlx.DB) (string, []migFunc, error) {
lastVer, err := getLastMigrationVersion(db)
if err != nil {
return "", nil, err
}
// Iterate through the migration versions and get everything above the last
// upgraded semver.
var toRun []migFunc
for i, m := range migList {
if semver.Compare(m.version, lastVer) > 0 {
toRun = migList[i:]
break
}
}
return lastVer, toRun, nil
}
// getLastMigrationVersion returns the last migration semver recorded in the DB.
// If there isn't any, `v0.0.0` is returned.
func getLastMigrationVersion(db *sqlx.DB) (string, error) {
var v string
if err := db.Get(&v, `
SELECT COALESCE(
(SELECT value->>-1 FROM settings WHERE key='migrations'),
'v0.0.0')`); err != nil {
if dbutil.IsTableNotExistError(err) {
return "v0.0.0", nil
}
return v, err
}
return v, nil
}
// recordMigrationVersion inserts the given version (of DB migration) into the
// `migrations` array in the settings table.
func recordMigrationVersion(ver string, db *sqlx.DB) error {
_, err := db.Exec(fmt.Sprintf(`INSERT INTO settings (key, value)
VALUES('migrations', '["%s"]'::JSONB)
ON CONFLICT (key) DO UPDATE SET value = settings.value || EXCLUDED.value`, ver))
return err
}
// checkPendingUpgrade checks if the current database schema matches the expected binary version.
func checkPendingUpgrade(db *sqlx.DB) {
lastVer, toRun, err := getPendingMigrations(db)
if err != nil {
log.Fatalf("error checking migrations: %v", err)
}
// No migrations to run.
if len(toRun) == 0 {
return
}
var vers []string
for _, m := range toRun {
vers = append(vers, m.version)
}
log.Fatalf(`there are %d pending database upgrade(s): %v. The last upgrade was %s. Backup the database and run libredesk --upgrade`,
len(toRun), vers, lastVer)
}

View File

@@ -22,7 +22,7 @@ import (
)
const (
maxAvatarSizeMB = 5
maxAvatarSizeMB = 20
)
// handleGetUsers returns all users.
@@ -39,9 +39,7 @@ func handleGetUsers(r *fastglue.Request) error {
// handleGetUsersCompact returns all users in a compact format.
func handleGetUsersCompact(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
var app = r.Context.(*App)
agents, err := app.user.GetAllCompact()
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, "")
@@ -59,20 +57,33 @@ func handleGetUser(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid user `id`.", nil, envelope.InputError)
}
user, err := app.user.Get(id)
user, err := app.user.GetAgent(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(user)
}
// handleUpdateUserAvailability updates the current user availability.
func handleUpdateUserAvailability(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
status = string(r.RequestCtx.PostArgs().Peek("status"))
)
if err := app.user.UpdateAvailability(auser.ID, status); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("User availability updated successfully.")
}
// handleGetCurrentUserTeams returns the teams of a user.
func handleGetCurrentUserTeams(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -90,13 +101,7 @@ func handleUpdateCurrentUser(r *fastglue.Request) error {
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.Get(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Get current user.
currentUser, err := app.user.Get(user.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -154,8 +159,8 @@ func handleUpdateCurrentUser(r *fastglue.Request) error {
}
// Delete current avatar.
if currentUser.AvatarURL.Valid {
fileName := filepath.Base(currentUser.AvatarURL.String)
if user.AvatarURL.Valid {
fileName := filepath.Base(user.AvatarURL.String)
app.media.Delete(fileName)
}
@@ -212,9 +217,9 @@ func handleCreateUser(r *fastglue.Request) error {
}
// Render template and send email.
content, err := app.tmpl.RenderTemplate(tmpl.TmplWelcome, map[string]interface{}{
content, err := app.tmpl.RenderInMemoryTemplate(tmpl.TmplWelcome, map[string]any{
"ResetToken": resetToken,
"Email": user.Email,
"Email": user.Email.String,
})
if err != nil {
app.lo.Error("error rendering template", "error", err)
@@ -228,7 +233,7 @@ func handleCreateUser(r *fastglue.Request) error {
Provider: notifier.ProviderEmail,
}); err != nil {
app.lo.Error("error sending notification message", "error", err)
return r.SendEnvelope("User created successfully, but error sending welcome email.")
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, "User created successfully, but could not send welcome email.", nil))
}
}
return r.SendEnvelope("User created successfully.")
@@ -305,7 +310,7 @@ func handleGetCurrentUser(r *fastglue.Request) error {
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
u, err := app.user.Get(auser.ID)
u, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -320,14 +325,14 @@ func handleDeleteAvatar(r *fastglue.Request) error {
)
// Get user
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Valid str?
if user.AvatarURL.String == "" {
return r.SendEnvelope(true)
return r.SendEnvelope("Avatar deleted successfully.")
}
fileName := filepath.Base(user.AvatarURL.String)
@@ -336,8 +341,8 @@ func handleDeleteAvatar(r *fastglue.Request) error {
if err := app.media.Delete(fileName); err != nil {
return sendErrorEnvelope(r, err)
}
err = app.user.UpdateAvatar(user.ID, "")
if err != nil {
if err = app.user.UpdateAvatar(user.ID, ""); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("Avatar deleted successfully.")
@@ -352,16 +357,17 @@ func handleResetPassword(r *fastglue.Request) error {
email = string(p.Peek("email"))
)
if ok && auser.ID > 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "User is already logged in", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "User is already logged in, Please logout to reset password.", nil, envelope.InputError)
}
if email == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty `email`", nil, envelope.InputError)
}
user, err := app.user.GetByEmail(email)
user, err := app.user.GetAgentByEmail(email)
if err != nil {
return sendErrorEnvelope(r, err)
// Send 200 even if user not found, to prevent email enumeration.
return r.SendEnvelope("Reset password email sent successfully.")
}
token, err := app.user.SetResetPasswordToken(user.ID)
@@ -370,10 +376,9 @@ func handleResetPassword(r *fastglue.Request) error {
}
// Send email.
content, err := app.tmpl.RenderTemplate(tmpl.TmplResetPassword,
map[string]string{
"ResetToken": token,
})
content, err := app.tmpl.RenderInMemoryTemplate(tmpl.TmplResetPassword, map[string]string{
"ResetToken": token,
})
if err != nil {
app.lo.Error("error rendering template", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error rendering template", nil, envelope.GeneralError)
@@ -385,8 +390,8 @@ func handleResetPassword(r *fastglue.Request) error {
Content: content,
Provider: notifier.ProviderEmail,
}); err != nil {
app.lo.Error("error sending notification message", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error sending notification message", nil, envelope.GeneralError)
app.lo.Error("error sending password reset email", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error sending password reset email", nil, envelope.GeneralError)
}
return r.SendEnvelope("Reset password email sent successfully.")

View File

@@ -16,7 +16,7 @@ func handleGetUserViews(r *fastglue.Request) error {
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -37,7 +37,7 @@ func handleCreateUserView(r *fastglue.Request) error {
if err := r.Decode(&view, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
}
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -46,7 +46,7 @@ func handleCreateUserView(r *fastglue.Request) error {
}
if string(view.Filters) == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty view `Filter`", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Please provide at least one filter", nil, envelope.InputError)
}
if err := app.view.Create(view.Name, view.Filters, user.ID); err != nil {
@@ -71,7 +71,7 @@ func handleDeleteUserView(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty view `ID`", nil, envelope.InputError)
}
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -109,7 +109,7 @@ func handleUpdateUserView(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
}
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}

View File

@@ -2,6 +2,7 @@
[app]
log_level = "debug"
env = "dev"
check_updates = true
# HTTP server.
[app.server]
@@ -9,16 +10,17 @@ address = "0.0.0.0:9000"
socket = ""
read_timeout = "5s"
write_timeout = "5s"
max_body_size = 10000000
max_body_size = 500000000
read_buffer_size = 4096
keepalive_timeout = "10s"
# File upload provider.
# File upload provider to use, either `fs` or `s3`.
[upload]
provider = "fs"
# Filesytem provider.
[upload.fs]
upload_path = '/home/ubuntu/uploads'
upload_path = 'uploads'
# S3 provider.
[upload.s3]
@@ -32,10 +34,12 @@ expiry = "6h"
# Postgres.
[db]
# If using docker compose, use the service name as the host. e.g. db
host = "127.0.0.1"
port = 5432
user = "postgres"
password = "postgres"
# Update the following values with your database credentials.
user = "libredesk"
password = "libredesk"
database = "libredesk"
ssl_mode = "disable"
max_open = 30
@@ -44,6 +48,7 @@ max_lifetime = "300s"
# Redis.
[redis]
# If using docker compose, use the service name as the host. e.g. redis:6379
address = "127.0.0.1:6379"
password = ""
db = 0
@@ -69,4 +74,4 @@ autoassign_interval = "5m"
unsnooze_interval = "5m"
[sla]
evaluation_interval = "5m"
evaluation_interval = "5m"

62
docker-compose.yml Normal file
View File

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

View File

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

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

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

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

@@ -0,0 +1,48 @@
# Installation
Libredesk is a single binary application that requires postgres and redis to run. You can install it using the binary or docker.
## Binary
1. Download the [latest release](https://github.com/abhinavxd/libredesk/releases) and extract the libredesk binary.
2. `./libredesk --install` to install the tables in the Postgres DB (⩾ 13) and set the System user password.
3. Run `./libredesk` and visit `http://localhost:9000` and login with the email `System` and the password you set during installation.
!!! Tip
To set the System user password during installation, set the environment variables:
`LIBREDESK_SYSTEM_USER_PASSWORD=xxxxxxxxxxx ./libredesk --install`
## Docker
The latest image is available on DockerHub at `libredesk/libredesk:latest`
The recommended method is to download the [docker-compose.yml](https://github.com/abhinavxd/libredesk/blob/main/docker-compose.yml) file, customize it for your environment and then to simply run `docker compose up -d`.
```shell
# Download the compose file and the sample config file in the current directory.
curl -LO https://github.com/abhinavxd/libredesk/raw/main/docker-compose.yml
curl -LO https://github.com/abhinavxd/libredesk/raw/main/config.sample.toml
# Copy the config.sample.toml to config.toml and edit it as needed.
cp config.sample.toml config.toml
# Run the services in the background.
docker compose up -d
# Setting System user password.
docker exec -it libredesk_app ./libredesk --set-system-user-password
```
Go to `http://localhost:9000` and login with the email `System` and the password you set using the `--set-system-user-password` command.
---
## Compiling from source
To compile the latest unreleased version (`main` branch):
1. Make sure `go`, `nodejs`, and `pnpm` are installed on your system.
2. `git clone git@github.com:abhinavxd/libredesk.git`
3. `cd libredesk && make`. This will generate the `libredesk` binary.

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

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

34
docs/mkdocs.yml Normal file
View File

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

View File

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

View File

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

View File

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

1863
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -46,9 +46,18 @@
@create-view="openCreateViewForm = true"
@edit-view="editView"
@delete-view="deleteView"
@create-conversation="() => openCreateConversationDialog = true"
>
<PageHeader />
<RouterView />
<div class="flex flex-col h-screen">
<!-- Show app update only in admin routes -->
<AppUpdate v-if="route.path.startsWith('/admin')" />
<!-- Common header for all pages -->
<PageHeader />
<!-- Main content -->
<RouterView class="flex-grow" />
</div>
<ViewForm v-model:openDialog="openCreateViewForm" v-model:view="view" />
</Sidebar>
</div>
@@ -56,6 +65,9 @@
<!-- Command box -->
<Command />
<!-- Create conversation dialog -->
<CreateConversation v-model="openCreateConversationDialog" />
</template>
<script setup>
@@ -73,12 +85,15 @@ import { useTeamStore } from '@/stores/team'
import { useSlaStore } from '@/stores/sla'
import { useMacroStore } from '@/stores/macro'
import { useTagStore } from '@/stores/tag'
import { useIdleDetection } from '@/composables/useIdleDetection'
import PageHeader from './components/layout/PageHeader.vue'
import ViewForm from '@/features/view/ViewForm.vue'
import AppUpdate from '@/components/update/AppUpdate.vue'
import api from '@/api'
import { toast as sooner } from 'vue-sonner'
import Sidebar from '@/components/sidebar/Sidebar.vue'
import Command from '@/features/command/CommandBox.vue'
import CreateConversation from '@/features/conversation/CreateConversation.vue'
import { Inbox, Shield, FileLineChart } from 'lucide-vue-next'
import { useRoute } from 'vue-router'
import {
@@ -107,8 +122,11 @@ const tagStore = useTagStore()
const userViews = ref([])
const view = ref({})
const openCreateViewForm = ref(false)
const openCreateConversationDialog = ref(false)
initWS()
useIdleDetection()
onMounted(() => {
initToaster()
listenViewRefresh()
@@ -117,8 +135,10 @@ onMounted(() => {
// initialize data stores
const initStores = async () => {
if (!userStore.userID) {
await userStore.getCurrentUser()
}
await Promise.allSettled([
userStore.getCurrentUser(),
getUserViews(),
conversationStore.fetchStatuses(),
conversationStore.fetchPriorities(),

View File

@@ -1,7 +1,27 @@
<template>
<RouterView />
<RouterView />
</template>
<script setup>
import { onMounted } from 'vue'
import { RouterView } from 'vue-router'
</script>
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useEmitter } from '@/composables/useEmitter'
import { toast as sooner } from 'vue-sonner'
const emitter = useEmitter()
onMounted(() => {
initToaster()
})
const initToaster = () => {
emitter.on(EMITTER_EVENTS.SHOW_TOAST, (message) => {
if (message.variant === 'destructive') {
sooner.error(message.description)
} else {
sooner.success(message.description)
}
})
}
</script>

View File

@@ -35,6 +35,7 @@ http.interceptors.request.use((request) => {
const searchConversations = (params) => http.get('/api/v1/conversations/search', { params })
const searchMessages = (params) => http.get('/api/v1/messages/search', { params })
const searchContacts = (params) => http.get('/api/v1/contacts/search', { params })
const resetPassword = (data) => http.post('/api/v1/users/reset-password', data)
const setPassword = (data) => http.post('/api/v1/users/set-password', data)
const deleteUser = (id) => http.delete(`/api/v1/users/${id}`)
@@ -90,6 +91,7 @@ const createOIDC = (data) =>
'Content-Type': 'application/json'
}
})
const testOIDC = (data) => http.post('/api/v1/oidc/test', data)
const getAllEnabledOIDC = () => http.get('/api/v1/oidc/enabled')
const getAllOIDC = () => http.get('/api/v1/oidc')
const getOIDC = (id) => http.get(`/api/v1/oidc/${id}`)
@@ -168,10 +170,12 @@ const updateCurrentUser = (data) =>
const deleteUserAvatar = () => http.delete('/api/v1/users/me/avatar')
const getCurrentUser = () => http.get('/api/v1/users/me')
const getCurrentUserTeams = () => http.get('/api/v1/users/me/teams')
const updateCurrentUserAvailability = (data) => http.put('/api/v1/users/me/availability', data)
const getTags = () => http.get('/api/v1/tags')
const upsertTags = (uuid, data) => http.post(`/api/v1/conversations/${uuid}/tags`, data)
const updateAssignee = (uuid, assignee_type, data) => http.put(`/api/v1/conversations/${uuid}/assignee/${assignee_type}`, data)
const removeAssignee = (uuid, assignee_type) => http.put(`/api/v1/conversations/${uuid}/assignee/${assignee_type}/remove`)
const createConversation = (data) => http.post('/api/v1/conversations', data)
const updateConversationStatus = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/status`, data)
const updateConversationPriority = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/priority`, data)
const updateAssigneeLastSeen = (uuid) => http.put(`/api/v1/conversations/${uuid}/last-seen`)
@@ -263,6 +267,7 @@ const updateView = (id, data) =>
const deleteView = (id) => http.delete(`/api/v1/views/me/${id}`)
const getAiPrompts = () => http.get('/api/v1/ai/prompts')
const aiCompletion = (data) => http.post('/api/v1/ai/completion', data)
const updateAIProvider = (data) => http.put('/api/v1/ai/provider', data)
export default {
login,
@@ -322,12 +327,15 @@ export default {
uploadMedia,
updateAssigneeLastSeen,
updateUser,
updateCurrentUserAvailability,
updateAutomationRule,
updateAutomationRuleWeights,
updateAutomationRulesExecutionMode,
updateAIProvider,
createAutomationRule,
toggleAutomationRule,
deleteAutomationRule,
createConversation,
sendMessage,
retryMessage,
createUser,
@@ -344,6 +352,7 @@ export default {
getAllEnabledOIDC,
getOIDC,
updateOIDC,
testOIDC,
deleteOIDC,
getTemplate,
getTemplates,
@@ -371,5 +380,6 @@ export default {
aiCompletion,
searchConversations,
searchMessages,
searchContacts,
removeAssignee,
}

View File

@@ -6,13 +6,6 @@
font-size: 16px;
}
.page-content {
height: 100vh;
overflow-y: scroll;
padding-bottom: 100px;
background-color: #fff;
}
@layer base {
html,
body {
@@ -25,6 +18,49 @@
overflow-x: auto;
}
}
.native-html {
p {
margin-bottom: 0.5rem;
}
ul {
list-style-type: disc;
margin-left: 1.5rem;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
ol {
list-style-type: decimal;
margin-left: 1.5rem;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
li {
padding-left: 0.25rem;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-size: 1.25rem;
font-weight: 700;
}
a {
color: #0066cc;
cursor: pointer;
&:hover {
color: #003d7a;
}
}
}
}
// Theme.
@@ -32,66 +68,65 @@
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border:240 5.9% 90%;
--input:240 5.9% 90%;
--ring:240 5.9% 10%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 5.9% 10%;
--radius: 0.75rem;
}
.dark {
--background:240 10% 3.9%;
--foreground:0 0% 98%;
--card:240 10% 3.9%;
--card-foreground:0 0% 98%;
--popover:240 10% 3.9%;
--popover-foreground:0 0% 98%;
--primary:0 0% 98%;
--primary-foreground:240 5.9% 10%;
--secondary:240 3.7% 15.9%;
--secondary-foreground:0 0% 98%;
--muted:240 3.7% 15.9%;
--muted-foreground:240 5% 64.9%;
--accent:240 3.7% 15.9%;
--accent-foreground:0 0% 98%;
--destructive:0 62.8% 30.6%;
--destructive-foreground:0 0% 98%;
--border:240 3.7% 15.9%;
--input:240 3.7% 15.9%;
--ring:240 4.9% 83.9%;
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
}
}
@layer base {
:root {
--vis-tooltip-background-color: none !important;
@@ -239,7 +274,7 @@
// Sidebar start
@layer base {
:root {
--sidebar-background: 0 0% 97%;
--sidebar-background: 0 0% 96%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
@@ -320,3 +355,7 @@ a[data-active='false']:hover {
opacity: 1;
}
}
[data-radix-popper-content-wrapper] {
z-index: 9999 !important;
}

View File

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

View File

@@ -18,6 +18,7 @@ import {
SidebarProvider,
SidebarRail
} from '@/components/ui/sidebar'
import { useAppSettingsStore } from '@/stores/appSettings'
import {
ChevronRight,
EllipsisVertical,
@@ -43,8 +44,9 @@ defineProps({
userViews: { type: Array, default: () => [] }
})
const userStore = useUserStore()
const settingsStore = useAppSettingsStore()
const route = useRoute()
const emit = defineEmits(['createView', 'editView', 'deleteView'])
const emit = defineEmits(['createView', 'editView', 'deleteView', 'createConversation'])
const openCreateViewDialog = () => {
emit('createView')
@@ -70,6 +72,8 @@ const isInboxRoute = (path) => {
}
const sidebarOpen = useStorage('mainSidebarOpen', true)
const teamInboxOpen = useStorage('teamInboxOpen', true)
const viewInboxOpen = useStorage('viewInboxOpen', true)
</script>
<template>
@@ -122,9 +126,13 @@ const sidebarOpen = useStorage('mainSidebarOpen', true)
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton :isActive="isActiveParent('/admin')" asChild>
<div>
<div class="flex items-center justify-between w-full">
<span class="font-semibold text-xl">Admin</span>
</div>
<!-- App version -->
<div class="text-xs text-muted-foreground ml-2">
({{ settingsStore.settings['app.version'] }})
</div>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
@@ -222,15 +230,27 @@ const sidebarOpen = useStorage('mainSidebarOpen', true)
<div class="flex items-center justify-between w-full">
<div class="font-semibold text-xl">Inbox</div>
<div class="ml-auto">
<router-link :to="{ name: 'search' }">
<div class="flex items-center bg-accent p-2 rounded-full">
<Search
class="transition-transform duration-200 hover:scale-110 cursor-pointer"
<div class="flex items-center space-x-2">
<div
class="flex items-center bg-accent p-2 rounded-full cursor-pointer"
@click="emit('createConversation')"
>
<Plus
class="transition-transform duration-200 hover:scale-110"
size="15"
stroke-width="2.5"
/>
</div>
</router-link>
<router-link :to="{ name: 'search' }">
<div class="flex items-center bg-accent p-2 rounded-full">
<Search
class="transition-transform duration-200 hover:scale-110 cursor-pointer"
size="15"
stroke-width="2.5"
/>
</div>
</router-link>
</div>
</div>
</div>
</SidebarMenuButton>
@@ -269,7 +289,12 @@ const sidebarOpen = useStorage('mainSidebarOpen', true)
</SidebarMenuItem>
<!-- Team Inboxes -->
<Collapsible defaultOpen class="group/collapsible" v-if="userTeams.length">
<Collapsible
defaultOpen
class="group/collapsible"
v-if="userTeams.length"
v-model:open="teamInboxOpen"
>
<SidebarMenuItem>
<CollapsibleTrigger as-child>
<SidebarMenuButton asChild>
@@ -301,7 +326,7 @@ const sidebarOpen = useStorage('mainSidebarOpen', true)
</Collapsible>
<!-- Views -->
<Collapsible class="group/collapsible" defaultOpen>
<Collapsible class="group/collapsible" defaultOpen v-model:open="viewInboxOpen">
<SidebarMenuItem>
<CollapsibleTrigger as-child>
<SidebarMenuButton asChild>
@@ -315,17 +340,14 @@ const sidebarOpen = useStorage('mainSidebarOpen', true)
class="rounded-lg cursor-pointer opacity-0 transition-all duration-200 group-hover:opacity-100 hover:bg-gray-200 hover:shadow-sm text-gray-600 hover:text-gray-800 transform hover:scale-105 active:scale-100 p-1"
/>
</div>
<ChevronRight
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
v-if="userViews.length"
/>
</router-link>
</SidebarMenuButton>
</CollapsibleTrigger>
<SidebarMenuAction>
<ChevronRight
class="transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
v-if="userViews.length"
/>
</SidebarMenuAction>
<CollapsibleContent>
<SidebarMenuSub v-for="view in userViews" :key="view.id">
<SidebarMenuSubItem>
@@ -335,25 +357,24 @@ const sidebarOpen = useStorage('mainSidebarOpen', true)
asChild
>
<router-link :to="{ name: 'view-inbox', params: { viewID: view.id } }">
<span class="break-all w-24">{{ view.name }}</span>
<span class="break-words w-32 truncate">{{ view.name }}</span>
<SidebarMenuAction :showOnHover="true" class="mr-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<EllipsisVertical />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="() => editView(view)">
<span>Edit</span>
</DropdownMenuItem>
<DropdownMenuItem @click="() => deleteView(view)">
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuAction>
</router-link>
</SidebarMenuButton>
<SidebarMenuAction>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<EllipsisVertical />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="() => editView(view)">
<span>Edit</span>
</DropdownMenuItem>
<DropdownMenuItem @click="() => deleteView(view)">
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuAction>
</SidebarMenuSubItem>
</SidebarMenuSub>
</CollapsibleContent>

View File

@@ -1,82 +1,99 @@
<template>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<SidebarMenuButton size="lg"
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground p-0">
<Avatar class="h-8 w-8 rounded-lg">
<AvatarImage :src="userStore.avatar" alt="Abhinav" />
<AvatarFallback class="rounded-lg">
{{ userStore.getInitials }}
</AvatarFallback>
</Avatar>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">{{ userStore.getFullName }}</span>
<span class="truncate text-xs">{{ userStore.email }}</span>
</div>
<ChevronsUpDown class="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent class="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg" side="bottom"
:side-offset="4">
<DropdownMenuLabel class="p-0 font-normal">
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar class="h-8 w-8 rounded-lg">
<AvatarImage :src="userStore.avatar" alt="Abhinav" />
<AvatarFallback class="rounded-lg">
{{ userStore.getInitials }}
</AvatarFallback>
</Avatar>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">{{ userStore.getFullName }}</span>
<span class="truncate text-xs">{{ userStore.email }}</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<router-link to="/account" class="flex items-center">
<CircleUserRound size="18" class="mr-2" />
Account
</router-link>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem @click="logout">
<LogOut size="18" class="mr-2" />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<SidebarMenuButton
size="lg"
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground p-0"
>
<Avatar class="h-8 w-8 rounded-lg relative overflow-visible">
<AvatarImage :src="userStore.avatar" alt="" class="rounded-lg" />
<AvatarFallback class="rounded-lg">
{{ userStore.getInitials }}
</AvatarFallback>
<div
class="absolute bottom-0 right-0 h-2.5 w-2.5 rounded-full border border-background"
:class="{
'bg-green-500': userStore.user.availability_status === 'online',
'bg-amber-500':
userStore.user.availability_status === 'away' ||
userStore.user.availability_status === 'away_manual',
'bg-gray-400': userStore.user.availability_status === 'offline'
}"
></div>
</Avatar>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">{{ userStore.getFullName }}</span>
<span class="truncate text-xs">{{ userStore.email }}</span>
</div>
<ChevronsUpDown class="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
class="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
side="bottom"
:side-offset="4"
>
<DropdownMenuLabel class="p-0 font-normal space-y-1">
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar class="h-8 w-8 rounded-lg">
<AvatarImage :src="userStore.avatar" alt="Abhinav" />
<AvatarFallback class="rounded-lg">
{{ userStore.getInitials }}
</AvatarFallback>
</Avatar>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">{{ userStore.getFullName }}</span>
<span class="truncate text-xs">{{ userStore.email }}</span>
</div>
</div>
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm justify-between">
<span class="text-muted-foreground">Away</span>
<Switch
:checked="
userStore.user.availability_status === 'away' ||
userStore.user.availability_status === 'away_manual'
"
@update:checked="(val) => userStore.updateUserAvailability(val ? 'away' : 'online')"
/>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem @click.prevent="router.push({ name: 'account' })">
<CircleUserRound size="18" class="mr-2" />
Account
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem @click="logout">
<LogOut size="18" class="mr-2" />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>
<script setup>
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import {
SidebarMenuButton,
} from '@/components/ui/sidebar'
import {
Avatar,
AvatarFallback,
AvatarImage,
} from '@/components/ui/avatar'
import {
ChevronsUpDown,
CircleUserRound,
LogOut,
} from 'lucide-vue-next'
import { SidebarMenuButton } from '@/components/ui/sidebar'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Switch } from '@/components/ui/switch'
import { ChevronsUpDown, CircleUserRound, LogOut } from 'lucide-vue-next'
import { useUserStore } from '@/stores/user'
import { useRouter } from 'vue-router'
const userStore = useUserStore()
const router = useRouter()
const logout = () => {
window.location.href = '/logout'
window.location.href = '/logout'
}
</script>
</script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,25 @@
<template>
<div
v-if="appSettingsStore.settings['app.update']?.update?.is_new"
class="p-2 mb-2 border-b bg-secondary text-secondary-foreground"
>
A new update is available:
{{ appSettingsStore.settings['app.update'].update.release_version }} ({{
appSettingsStore.settings['app.update'].update.release_date
}})
<a
:href="appSettingsStore.settings['app.update'].update.url"
target="_blank"
nofollow
noreferrer
class="underline ml-2"
>
View details
</a>
</div>
</template>
<script setup>
import { useAppSettingsStore } from '@/stores/appSettings'
const appSettingsStore = useAppSettingsStore()
</script>

View File

@@ -0,0 +1,59 @@
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import { useUserStore } from '@/stores/user'
import { debounce } from '@/utils/debounce'
import { useStorage } from '@vueuse/core'
export function useIdleDetection () {
const userStore = useUserStore()
// 4 minutes
const AWAY_THRESHOLD = 4 * 60 * 1000
// 1 minute
const CHECK_INTERVAL = 60 * 1000
// Store last activity time in localStorage to sync across tabs
const lastActivity = useStorage('last_active', Date.now())
const timer = ref(null)
function resetTimer () {
if (userStore.user.availability_status === 'away' || userStore.user.availability_status === 'offline') {
userStore.updateUserAvailability('online', false)
}
const now = Date.now()
if (lastActivity.value < now) {
lastActivity.value = now
}
}
const debouncedResetTimer = debounce(resetTimer, 200)
function checkIdle () {
if (Date.now() - lastActivity.value > AWAY_THRESHOLD &&
userStore.user.availability_status === 'online') {
userStore.updateUserAvailability('away', false)
}
}
onMounted(() => {
window.addEventListener('mousemove', debouncedResetTimer)
window.addEventListener('keypress', debouncedResetTimer)
window.addEventListener('click', debouncedResetTimer)
timer.value = setInterval(checkIdle, CHECK_INTERVAL)
})
onBeforeUnmount(() => {
window.removeEventListener('mousemove', debouncedResetTimer)
window.removeEventListener('keypress', debouncedResetTimer)
window.removeEventListener('click', debouncedResetTimer)
if (timer.value) {
clearInterval(timer.value)
timer.value = null
}
})
// Watch for lastActivity changes in localStorage to handle multi-tab sync
watch(lastActivity, (newVal, oldVal) => {
if (newVal > oldVal) {
resetTimer()
}
})
}

View File

@@ -18,5 +18,5 @@ export function useSla (dueAt, actualAt) {
clearInterval(intervalId)
})
})
return { sla, updateSla }
return sla
}

View File

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

View File

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

View File

@@ -10,7 +10,6 @@
<div class="flex items-center justify-between">
<div class="flex gap-5">
<div class="w-48">
<!-- Type -->
<Select
v-model="action.type"
@@ -109,15 +108,13 @@
</div>
<div
class="box p-2 h-96 min-h-96"
v-if="action.type && conversationActions[action.type]?.type === 'richtext'"
class="pl-0 shadow"
>
<QuillEditor
theme="snow"
v-model:content="action.value[0]"
contentType="html"
@update:content="(value) => handleValueChange(value, index)"
class="h-32 mb-12"
<Editor
v-model:htmlContent="action.value[0]"
@update:htmlContent="(value) => handleEditorChange(value, index)"
:placeholder="'Shift + Enter to add new line'"
/>
</div>
</div>
@@ -142,12 +139,12 @@ import {
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { QuillEditor } from '@vueup/vue-quill'
import '@vueup/vue-quill/dist/vue-quill.snow.css'
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { SelectTag } from '@/components/ui/select'
import { useConversationFilters } from '@/composables/useConversationFilters'
import { getTextFromHTML } from '@/utils/strings.js'
import Editor from '@/features/conversation/ConversationTextEditor.vue'
const props = defineProps({
actions: {
@@ -175,6 +172,16 @@ const handleValueChange = (value, index) => {
emitUpdate(index)
}
const handleEditorChange = (value, index) => {
// If text is empty, set HTML to empty string
const textContent = getTextFromHTML(value)
if (textContent.length === 0) {
value = ''
}
actions.value[index].value = [value]
emitUpdate(index)
}
const removeAction = (index) => {
emit('remove-action', index)
}

View File

@@ -31,7 +31,7 @@
</template>
</draggable>
</div>
<div v-else>
<div v-else class="space-y-5">
<RuleList
v-for="rule in rules"
:key="rule.id"

View File

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

View File

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

View File

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

View File

@@ -108,19 +108,6 @@
placeholder="Select tag"
/>
</div>
<div
v-if="action.type && config.actions[action.type]?.type === 'richtext'"
class="pl-0 shadow"
>
<QuillEditor
v-model:content="action.value[0]"
theme="snow"
contentType="html"
@update:content="(value) => updateValue(value, index)"
class="h-32 mb-12"
/>
</div>
</div>
</div>
</div>
@@ -139,14 +126,12 @@ import {
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { QuillEditor } from '@vueup/vue-quill'
import '@vueup/vue-quill/dist/vue-quill.snow.css'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { SelectTag } from '@/components/ui/select'
import { useTagStore } from '@/stores/tag'
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
const model = defineModel({
const model = defineModel("actions", {
type: Array,
required: true,
default: () => []

View File

@@ -13,16 +13,25 @@
<FormField v-slot="{ componentField }" name="message_content">
<FormItem>
<FormLabel>Response to be sent when macro is used</FormLabel>
<FormLabel>Response to be sent when macro is used (optional)</FormLabel>
<FormControl>
<QuillEditor
v-model:content="componentField.modelValue"
placeholder="Add a response (optional)"
theme="snow"
contentType="html"
class="h-32 mb-12"
@update:content="(value) => componentField.onChange(value)"
/>
<div class="box p-2 h-96 min-h-96">
<Editor
v-model:htmlContent="componentField.modelValue"
@update:htmlContent="(value) => componentField.onChange(value)"
:placeholder="'Shift + Enter to add new line'"
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="actions">
<FormItem>
<FormLabel> Actions (optional)</FormLabel>
<FormControl>
<ActionBuilder v-model:actions="componentField.modelValue" :config="actionConfig" @update:actions="(value) => componentField.onChange(value)" />
</FormControl>
<FormMessage />
</FormItem>
@@ -106,16 +115,6 @@
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="actions">
<FormItem>
<FormLabel> Actions </FormLabel>
<FormControl>
<ActionBuilder v-bind="componentField" :config="actionConfig" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<Button type="submit" :isLoading="isLoading">{{ submitLabel }}</Button>
</form>
</template>
@@ -133,9 +132,8 @@ import ActionBuilder from '@/features/admin/macros/ActionBuilder.vue'
import { useConversationFilters } from '@/composables/useConversationFilters'
import { useUsersStore } from '@/stores/users'
import { useTeamStore } from '@/stores/team'
import { getTextFromHTML } from '@/utils/strings.js'
import { formSchema } from './formSchema.js'
import { QuillEditor } from '@vueup/vue-quill'
import '@vueup/vue-quill/dist/vue-quill.snow.css'
import {
Select,
SelectContent,
@@ -145,6 +143,7 @@ import {
SelectValue
} from '@/components/ui/select'
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
import Editor from '@/features/conversation/ConversationTextEditor.vue'
const { macroActions } = useConversationFilters()
const formLoading = ref(false)
@@ -181,6 +180,11 @@ const actionConfig = ref({
})
const onSubmit = form.handleSubmit(async (values) => {
// If the text of HTML is empty then set the HTML to empty string
const textContent = getTextFromHTML(values.message_content)
if (textContent.length === 0) {
values.message_content = ''
}
props.submitForm(values)
})

View File

@@ -1,4 +1,5 @@
import * as z from 'zod'
import { getTextFromHTML } from '@/utils/strings.js'
const actionSchema = z.array(
z.object({
@@ -10,8 +11,42 @@ const actionSchema = z.array(
export const formSchema = z.object({
name: z.string().min(1, 'Macro name is required'),
message_content: z.string().optional(),
actions: actionSchema,
actions: actionSchema.optional().default([]), // Default to empty array if not provided
visibility: z.enum(['all', 'team', 'user']),
team_id: z.string().nullable().optional(),
user_id: z.string().nullable().optional(),
})
})
.refine(
(data) => {
// Check if message_content has non-empty text after stripping HTML
const hasMessageContent = getTextFromHTML(data.message_content || '').trim().length > 0
// Check if actions has at least one valid action
const hasValidActions = data.actions && data.actions.length > 0
// Either message content or actions must be valid
return hasMessageContent || hasValidActions
},
{
message: 'Either message content or actions are required',
// Field path to highlight
path: ['message_content'],
}
)
.refine(
(data) => {
// If visibility is 'team', team_id is required
if (data.visibility === 'team' && !data.team_id) {
return false
}
// If visibility is 'user', user_id is required
if (data.visibility === 'user' && !data.user_id) {
return false
}
// Otherwise, validation passes
return true
},
{
message: 'team is required when visibility is "team", and user is required when visibility is "user"',
// Field path to highlight
path: ['visibility'],
}
)

View File

@@ -65,6 +65,7 @@
<Input type="number" placeholder="2" v-bind="componentField" />
</FormControl>
<FormMessage />
<FormDescription> Maximum concurrent connections to the server. </FormDescription>
</FormItem>
</FormField>
@@ -76,6 +77,10 @@
<Input type="text" placeholder="15s" v-bind="componentField" />
</FormControl>
<FormMessage />
<FormDescription>
Time to wait for new activity on a connection before closing it and removing it from the
pool (s for second, m for minute)
</FormDescription>
</FormItem>
</FormField>
@@ -87,6 +92,10 @@
<Input type="text" placeholder="5s" v-bind="componentField" />
</FormControl>
<FormMessage />
<FormDescription>
Time to wait for new activity on a connection before closing it and removing it from the
pool (s for second, m for minute, h for hour).
</FormDescription>
</FormItem>
</FormField>
@@ -139,6 +148,7 @@
<Input type="number" placeholder="2" v-bind="componentField" />
</FormControl>
<FormMessage />
<FormDescription> Number of times to retry when a message fails. </FormDescription>
</FormItem>
</FormField>

View File

@@ -3,7 +3,7 @@ import { isGoDuration } from '@/utils/strings';
export const smtpConfigSchema = z.object({
enabled: z.boolean().describe('Enabled status').default(false),
username: z.string().describe('SMTP username').email().nonempty({
username: z.string().describe('SMTP username').nonempty({
message: "SMTP username is required"
}),
host: z.string().describe('SMTP host').nonempty({
@@ -51,8 +51,8 @@ export const smtpConfigSchema = z.object({
auth_protocol: z
.enum(['plain', 'login', 'cram', 'none'])
.describe('Authentication protocol'),
email_address: z.string().describe('Email address').email().nonempty({
message: "Email address is required"
email_address: z.string().describe('From email address with name (e.g., "Name <email@example.com>")').nonempty({
message: "From email address is required"
}),
max_msg_retries: z
.number({

View File

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

View File

@@ -13,7 +13,11 @@
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Input type="text" placeholder="This role is for all support agents" v-bind="componentField" />
<Input
type="text"
placeholder="This role is for all support agents"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -24,13 +28,19 @@
<div v-for="entity in permissions" :key="entity.name" class="box p-4">
<p class="text-lg mb-5">{{ entity.name }}</p>
<div class="space-y-4">
<FormField v-for="permission in entity.permissions" :key="permission.name" type="checkbox"
:name="permission.name">
<FormField
v-for="permission in entity.permissions"
:key="permission.name"
type="checkbox"
:name="permission.name"
>
<FormItem class="flex flex-col gap-y-5 space-y-0 rounded-lg">
<div class="flex space-x-3">
<FormControl>
<Checkbox :checked="selectedPermissions.includes(permission.name)"
@update:checked="(newValue) => handleChange(newValue, permission.name)" />
<Checkbox
:checked="selectedPermissions.includes(permission.name)"
@update:checked="(newValue) => handleChange(newValue, permission.name)"
/>
<FormLabel>{{ permission.label }}</FormLabel>
</FormControl>
</div>
@@ -69,7 +79,7 @@ const props = defineProps({
},
isLoading: {
type: Boolean,
required: false,
required: false
}
})
@@ -77,7 +87,8 @@ const permissions = ref([
{
name: 'Conversation',
permissions: [
{ name: 'conversations:read', label: 'View conversations' },
{ name: 'conversations:read', label: 'View conversation' },
{ name: 'conversations:write', label: 'Create conversation' },
{ name: 'conversations:read_assigned', label: 'View conversations assigned to me' },
{ name: 'conversations:read_all', label: 'View all conversations' },
{ name: 'conversations:read_unassigned', label: 'View all unassigned conversations' },
@@ -89,7 +100,7 @@ const permissions = ref([
{ name: 'conversations:update_tags', label: 'Add or remove conversation tags' },
{ name: 'messages:read', label: 'View conversation messages' },
{ name: 'messages:write', label: 'Send messages in conversations' },
{ name: 'view:manage', label: 'Create and manage conversation views' },
{ name: 'view:manage', label: 'Create and manage conversation views' }
]
},
{
@@ -110,8 +121,9 @@ const permissions = ref([
{ name: 'reports:manage', label: 'Manage Reports' },
{ name: 'business_hours:manage', label: 'Manage Business Hours' },
{ name: 'sla:manage', label: 'Manage SLA Policies' },
{ name: 'ai:manage', label: 'Manage AI Features' }
]
},
}
])
const selectedPermissions = ref([])

View File

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

View File

@@ -1,7 +1,11 @@
<template>
<CommandDialog :open="open" @update:open="handleOpenChange" class="z-[51]">
<CommandDialog
:open="open"
@update:open="handleOpenChange"
class="z-[51] !min-w-[50vw] !min-h-[60vh]"
>
<CommandInput placeholder="Type a command or search..." @keydown="onInputKeydown" />
<CommandList class="!min-h-[400px]">
<CommandList class="!min-h-[60vh] !min-w-[50vw]">
<CommandEmpty>
<p class="text-muted-foreground">No command available</p>
</CommandEmpty>
@@ -10,7 +14,7 @@
<CommandGroup
heading="Conversations"
value="conversations"
v-if="nestedCommand === null && conversationStore.current"
v-if="nestedCommand === null && conversationStore.hasConversationOpen"
>
<CommandItem value="conv-snooze" @select="setNestedCommand('snooze')"> Snooze </CommandItem>
<CommandItem value="conv-resolve" @select="resolveConversation"> Resolve </CommandItem>
@@ -32,12 +36,12 @@
</CommandGroup>
<!-- Macros -->
<!-- TODO move to a separate component -->
<div v-if="nestedCommand === 'apply-macro'" class="bg-background">
<CommandGroup heading="Apply macro" class="pb-2">
<div class="min-h-[400px] overflow-auto">
<div class="grid grid-cols-12 gap-3">
<div class="col-span-4 border-r border-border/30 pr-2">
<!-- Left Column: Macro List (30%) -->
<div class="col-span-4 pr-2 border-r">
<CommandItem
v-for="(macro, index) in macroStore.macroOptions"
:key="macro.value"
@@ -45,25 +49,29 @@
:data-index="index"
@select="handleApplyMacro(macro)"
class="px-3 py-2 rounded-md cursor-pointer transition-all duration-200 hover:bg-primary/10 hover:text-primary"
:class="{ 'bg-primary/5 text-primary': selectedMacroIndex === index }"
>
<div class="flex items-center space-x-2 justify-start">
<Zap :size="14" class="text-primary" />
<span class="text-sm overflow">{{ macro.label }}</span>
<div class="flex items-center gap-2">
<Zap size="14" class="text-primary shrink-0" />
<span class="text-sm truncate w-full break-words whitespace-normal">{{
macro.label
}}</span>
</div>
</CommandItem>
</div>
<!-- Right Column: Macro Details (70%) -->
<div class="col-span-8 pl-2">
<div class="space-y-3 text-xs">
<!-- Reply Preview -->
<div v-if="replyContent" class="space-y-1">
<p class="text-xs font-semibold text-primary">Reply Preview</p>
<div
class="w-full min-h-200 p-2 bg-muted/50 rounded-md overflow-auto shadow-sm"
v-html="replyContent"
class="w-full min-h-200 p-2 bg-muted/50 rounded-md overflow-auto shadow-sm native-html"
v-dompurify-html="replyContent"
/>
</div>
<!-- Actions -->
<div v-if="otherActions.length > 0" class="space-y-1">
<p class="text-xs font-semibold text-primary">Actions</p>
<div class="space-y-1.5 max-w-sm">
@@ -105,6 +113,8 @@
</div>
</div>
</div>
<!-- Empty State -->
<div
v-if="!replyContent && otherActions.length === 0"
class="flex items-center justify-center h-20"
@@ -122,7 +132,6 @@
</CommandList>
<!-- Navigation -->
<!-- TODO: Move to a separate component -->
<div class="mt-2 px-4 py-2 text-xs text-gray-500 flex space-x-4">
<span><kbd>Enter</kbd> select</span>
<span><kbd></kbd>/<kbd></kbd> navigate</span>
@@ -132,7 +141,6 @@
</CommandDialog>
<!-- Date Picker for Custom Snooze -->
<!-- TODO: Move to a separate component -->
<Dialog :open="showDatePicker" @update:open="closeDatePicker">
<DialogContent class="sm:max-w-[425px]">
<DialogHeader>
@@ -219,7 +227,9 @@ watch([Meta_K, Ctrl_K], ([mac, win]) => {
const highlightedMacro = ref(null)
function handleApplyMacro(macro) {
conversationStore.setMacro(macro)
// Create a deep copy.
const plainMacro = JSON.parse(JSON.stringify(macro))
conversationStore.setMacro(plainMacro)
handleOpenChange()
}

View File

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

View File

@@ -1,5 +1,5 @@
<template>
<div class="max-h-[600px] overflow-y-auto">
<div class="editor-wrapper h-full overflow-y-auto">
<BubbleMenu
:editor="editor"
:tippy-options="{ duration: 100 }"
@@ -7,7 +7,7 @@
class="bg-white p-1 box will-change-transform"
>
<div class="flex space-x-1 items-center">
<DropdownMenu>
<DropdownMenu v-if="aiPrompts.length > 0">
<DropdownMenuTrigger>
<Button size="sm" variant="ghost" class="flex items-center justify-center">
<span class="flex items-center">
@@ -30,7 +30,7 @@
<Button
size="sm"
variant="ghost"
@click="isBold = !isBold"
@click.prevent="isBold = !isBold"
:active="isBold"
:class="{ 'bg-gray-200': isBold }"
>
@@ -39,22 +39,39 @@
<Button
size="sm"
variant="ghost"
@click="isItalic = !isItalic"
@click.prevent="isItalic = !isItalic"
:active="isItalic"
:class="{ 'bg-gray-200': isItalic }"
>
<Italic size="14" />
</Button>
<Button
size="sm"
variant="ghost"
@click.prevent="toggleBulletList"
:class="{ 'bg-gray-200': editor?.isActive('bulletList') }"
>
<List size="14" />
</Button>
<Button
size="sm"
variant="ghost"
@click.prevent="toggleOrderedList"
:class="{ 'bg-gray-200': editor?.isActive('orderedList') }"
>
<ListOrdered size="14" />
</Button>
</div>
</BubbleMenu>
<EditorContent :editor="editor" />
<EditorContent :editor="editor" class="native-html" />
</div>
</template>
<script setup>
import { ref, watch, watchEffect, onUnmounted } from 'vue'
import { useEditor, EditorContent, BubbleMenu } from '@tiptap/vue-3'
import { ChevronDown, Bold, Italic, Bot } from 'lucide-vue-next'
import { ChevronDown, Bold, Italic, Bot, List, ListOrdered } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
@@ -95,28 +112,7 @@ const getSelectionText = (from, to, doc) => doc.textBetween(from, to)
const editorConfig = {
extensions: [
// Lists are unstyled in tailwind, so need to add classes to them.
StarterKit.configure({
bulletList: {
HTMLAttributes: {
class: 'list-disc ml-6 my-2'
}
},
orderedList: {
HTMLAttributes: {
class: 'list-decimal ml-6 my-2'
}
},
listItem: {
HTMLAttributes: {
class: 'pl-1'
}
},
heading: {
HTMLAttributes: {
class: 'text-xl font-bold mt-4 mb-2'
}
}
}),
StarterKit.configure(),
Image.configure({ HTMLAttributes: { class: 'inline-image' } }),
Placeholder.configure({ placeholder: () => props.placeholder }),
Link
@@ -179,13 +175,20 @@ watchEffect(() => {
watch(
() => props.contentToSet,
(newContent) => {
if (newContent === '') {
editor.value?.commands.clearContent()
} else {
editor.value?.commands.setContent(newContent, true)
(newContentData) => {
if (!newContentData) return
try {
const parsedData = JSON.parse(newContentData)
const content = parsedData.content
if (content === '') {
editor.value?.commands.clearContent()
} else {
editor.value?.commands.setContent(content, true)
}
editor.value?.commands.focus()
} catch (e) {
console.error('Error parsing content data', e)
}
editor.value?.commands.focus()
}
)
@@ -231,6 +234,18 @@ watch(
onUnmounted(() => {
editor.value?.destroy()
})
const toggleBulletList = () => {
if (editor.value) {
editor.value.chain().focus().toggleBulletList().run()
}
}
const toggleOrderedList = () => {
if (editor.value) {
editor.value.chain().focus().toggleOrderedList().run()
}
}
</script>
<style lang="scss">
@@ -243,22 +258,26 @@ onUnmounted(() => {
height: 0;
}
// Editor height
.ProseMirror {
min-height: 80px !important;
max-height: 60% !important;
overflow-y: scroll !important;
// Ensure the parent div has a proper height
.editor-wrapper div[aria-expanded='false'] {
display: flex;
flex-direction: column;
height: 100%;
}
.fullscreen-tiptap-editor {
@apply p-0;
.ProseMirror {
min-height: 600px !important;
width: 90%;
scrollbar-width: none;
}
// Ensure the editor content has a proper height and breaks words
.tiptap.ProseMirror {
flex: 1;
min-height: 70px;
overflow-y: auto;
word-wrap: break-word !important;
overflow-wrap: break-word !important;
word-break: break-word;
white-space: pre-wrap;
max-width: 100%;
}
// Anchor tag styling
.tiptap {
a {
color: #0066cc;

View File

@@ -0,0 +1,345 @@
<template>
<Dialog :open="dialogOpen" @update:open="dialogOpen = false">
<DialogContent class="max-w-5xl w-full h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle>New Conversation</DialogTitle>
</DialogHeader>
<form @submit="createConversation" class="flex flex-col flex-1 overflow-hidden">
<div class="flex-1 space-y-4 pr-1 overflow-y-auto pb-2">
<FormField name="contact_email">
<FormItem class="relative">
<FormLabel>Email</FormLabel>
<FormControl>
<Input
type="email"
placeholder="Search contact by email or type new email"
v-model="emailQuery"
@input="handleSearchContacts"
autocomplete="off"
/>
</FormControl>
<FormMessage />
<ul
v-if="searchResults.length"
class="border rounded p-2 max-h-60 overflow-y-auto absolute bg-white w-full z-50 shadow-lg"
>
<li
v-for="contact in searchResults"
:key="contact.email"
@click="selectContact(contact)"
class="cursor-pointer p-2 hover:bg-gray-100 rounded"
>
{{ contact.first_name }} {{ contact.last_name }} ({{ contact.email }})
</li>
</ul>
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="first_name">
<FormItem>
<FormLabel>First Name</FormLabel>
<FormControl>
<Input type="text" placeholder="First Name" v-bind="componentField" required />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="last_name">
<FormItem>
<FormLabel>Last Name</FormLabel>
<FormControl>
<Input type="text" placeholder="Last Name" v-bind="componentField" required />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="subject">
<FormItem>
<FormLabel>Subject</FormLabel>
<FormControl>
<Input type="text" placeholder="Subject" v-bind="componentField" required />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="inbox_id">
<FormItem>
<FormLabel>Inbox</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue placeholder="Select an inbox" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem
v-for="option in inboxStore.options"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Set assigned team -->
<FormField v-slot="{ componentField }" name="team_id">
<FormItem>
<FormLabel>Assign team (optional)</FormLabel>
<FormControl>
<ComboBox
v-bind="componentField"
:items="[{ value: 'none', label: 'None' }, ...teamStore.options]"
placeholder="Search team"
defaultLabel="Assign team"
>
<template #item="{ item }">
<div class="flex items-center gap-3 py-2">
<div class="w-7 h-7 flex items-center justify-center">
<span v-if="item.emoji">{{ item.emoji }}</span>
<div
v-else
class="text-primary bg-muted rounded-full w-7 h-7 flex items-center justify-center"
>
<Users size="14" />
</div>
</div>
<span class="text-sm">{{ item.label }}</span>
</div>
</template>
<template #selected="{ selected }">
<div class="flex items-center gap-3" v-if="selected">
<div class="w-7 h-7 flex items-center justify-center">
{{ selected?.emoji }}
</div>
<span class="text-sm">{{ selected?.label || 'Select team' }}</span>
</div>
</template>
</ComboBox>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Set assigned agent -->
<FormField v-slot="{ componentField }" name="agent_id">
<FormItem>
<FormLabel>Assign agent (optional)</FormLabel>
<FormControl>
<ComboBox
v-bind="componentField"
:items="[{ value: 'none', label: 'None' }, ...uStore.options]"
placeholder="Search agent"
defaultLabel="Assign agent"
>
<template #item="{ item }">
<div class="flex items-center gap-3 py-2">
<Avatar class="w-8 h-8">
<AvatarImage
:src="item.value === 'none' ? '/default-avatar.png' : item.avatar_url"
:alt="item.value === 'none' ? 'N' : item.label.slice(0, 2)"
/>
<AvatarFallback>
{{ item.value === 'none' ? 'N' : item.label.slice(0, 2).toUpperCase() }}
</AvatarFallback>
</Avatar>
<span class="text-sm">{{ item.label }}</span>
</div>
</template>
<template #selected="{ selected }">
<div class="flex items-center gap-3">
<Avatar class="w-7 h-7" v-if="selected">
<AvatarImage
:src="
selected?.value === 'none'
? '/default-avatar.png'
: selected?.avatar_url
"
:alt="selected?.value === 'none' ? 'N' : selected?.label?.slice(0, 2)"
/>
<AvatarFallback>
{{
selected?.value === 'none'
? 'N'
: selected?.label?.slice(0, 2)?.toUpperCase()
}}
</AvatarFallback>
</Avatar>
<span class="text-sm">{{ selected?.label || 'Assign agent' }}</span>
</div>
</template>
</ComboBox>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField
v-slot="{ componentField }"
name="content"
class="flex-1 min-h-0 flex flex-col"
>
<FormItem class="flex flex-col flex-1">
<FormLabel>Message</FormLabel>
<FormControl class="flex-1 min-h-0 flex flex-col">
<div class="flex-1 min-h-0 flex flex-col">
<Editor
v-model:htmlContent="componentField.modelValue"
@update:htmlContent="(value) => componentField.onChange(value)"
:placeholder="'Shift + Enter to add new line'"
class="w-full flex-1 overflow-y-auto p-2 min-h-[200px] box"
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
<DialogFooter class="mt-4 pt-2 border-t shrink-0">
<Button type="submit" :disabled="loading" :isLoading="loading"> Submit </Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>
<script setup>
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { z } from 'zod'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { ref, defineModel, watch } from 'vue'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useEmitter } from '@/composables/useEmitter'
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
import { handleHTTPError } from '@/utils/http'
import { useInboxStore } from '@/stores/inbox'
import { useUsersStore } from '@/stores/users'
import { useTeamStore } from '@/stores/team'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import Editor from '@/features/conversation/ConversationTextEditor.vue'
import api from '@/api'
const dialogOpen = defineModel({
required: false,
default: () => false
})
const inboxStore = useInboxStore()
const uStore = useUsersStore()
const teamStore = useTeamStore()
const emitter = useEmitter()
const loading = ref(false)
const searchResults = ref([])
const emailQuery = ref('')
let timeoutId = null
const formSchema = z.object({
subject: z.string().min(3, 'Subject must be at least 3 characters'),
content: z.string().min(1, 'Message cannot be empty'),
inbox_id: z.any().refine((val) => inboxStore.options.some((option) => option.value === val), {
message: 'Inbox is required'
}),
team_id: z.any().optional(),
agent_id: z.any().optional(),
contact_email: z.string().email('Invalid email address'),
first_name: z.string().min(1, 'First name is required'),
last_name: z.string().min(1, 'Last name is required')
})
const form = useForm({
validationSchema: toTypedSchema(formSchema),
initialValues: {
inbox_id: null,
team_id: null,
agent_id: null,
subject: '',
content: '',
contact_email: '',
first_name: '',
last_name: ''
}
})
watch(emailQuery, (newVal) => {
form.setFieldValue('contact_email', newVal)
})
const handleSearchContacts = async () => {
clearTimeout(timeoutId)
timeoutId = setTimeout(async () => {
const query = emailQuery.value.trim()
if (query.length < 3) {
searchResults.value.splice(0)
return
}
try {
const resp = await api.searchContacts({ query })
searchResults.value = [...resp.data.data]
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})
searchResults.value.splice(0)
}
}, 300)
}
const selectContact = (contact) => {
emailQuery.value = contact.email
form.setFieldValue('first_name', contact.first_name)
form.setFieldValue('last_name', contact.last_name || '')
searchResults.value.splice(0)
}
const createConversation = form.handleSubmit(async (values) => {
loading.value = true
try {
await api.createConversation(values)
dialogOpen.value = false
form.resetForm()
emailQuery.value = ''
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})
} finally {
loading.value = false
}
})
</script>

View File

@@ -1,16 +1,16 @@
<template>
<div class="flex flex-wrap px-2 py-1">
<div class="flex flex-wrap">
<div class="flex flex-wrap gap-2">
<div
v-for="action in actions"
:key="action.type"
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 gap-2 py-1"
>
<div class="flex items-center space-x-2 px-3 py-2">
<div class="flex items-center space-x-2 px-2">
<component
:is="getIcon(action.type)"
size="16"
class="text-primary group-hover:text-primary"
class="text-gray-500 text-primary group-hover:text-primary"
/>
<Tooltip>
<TooltipTrigger as-child>
@@ -27,7 +27,7 @@
</div>
<button
@click.stop="onRemove(action)"
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="pr-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"
title="Remove action"
>
<X size="14" />

View File

@@ -1,327 +1,202 @@
<template>
<Dialog :open="openAIKeyPrompt" @update:open="openAIKeyPrompt = false">
<DialogContent class="sm:max-w-lg">
<DialogHeader class="space-y-2">
<DialogTitle>Enter OpenAI API Key</DialogTitle>
<DialogDescription>
OpenAI API key is not set or invalid. Please enter a valid API key to use AI features.
</DialogDescription>
</DialogHeader>
<Form v-slot="{ handleSubmit }" as="" keep-values :validation-schema="formSchema">
<form id="apiKeyForm" @submit="handleSubmit($event, updateProvider)">
<FormField v-slot="{ componentField }" name="apiKey">
<FormItem>
<FormLabel>API Key</FormLabel>
<FormControl>
<Input type="text" placeholder="Enter your API key" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</form>
<DialogFooter>
<Button
type="submit"
form="apiKeyForm"
:is-loading="isOpenAIKeyUpdating"
:disabled="isOpenAIKeyUpdating"
>
Save
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
<div class="text-foreground bg-background">
<!-- Fullscreen editor -->
<Dialog :open="isEditorFullscreen" @update:open="isEditorFullscreen = false">
<DialogContent
class="max-w-[70%] max-h-[70%] h-[90%] w-full bg-card text-card-foreground px-4 py-4"
class="max-w-[70%] max-h-[70%] h-[70%] bg-card text-card-foreground p-4 flex flex-col"
@escapeKeyDown="isEditorFullscreen = false"
hide-close-button="true"
:hide-close-button="true"
>
<div v-if="isEditorFullscreen" class="h-full flex flex-col">
<!-- Message type toggle -->
<div class="flex justify-between items-center border-b border-border pb-4">
<Tabs v-model="messageType" class="rounded-lg">
<TabsList class="bg-muted p-1 rounded-lg">
<TabsTrigger
value="reply"
class="px-3 py-1 rounded-lg transition-colors duration-200"
:class="{ 'bg-background text-foreground': messageType === 'reply' }"
>
Reply
</TabsTrigger>
<TabsTrigger
value="private_note"
class="px-3 py-1 rounded-lg transition-colors duration-200"
:class="{ 'bg-background text-foreground': messageType === 'private_note' }"
>
Private note
</TabsTrigger>
</TabsList>
</Tabs>
<span
class="text-muted-foreground hover:text-foreground transition-colors duration-200 cursor-pointer"
variant="ghost"
@click="isEditorFullscreen = false"
>
<Minimize2 size="18" />
</span>
</div>
<!-- CC and BCC fields -->
<div class="space-y-3 p-4 border-b border-border" v-if="messageType === 'reply'">
<div class="flex items-center space-x-2">
<label class="w-12 text-sm font-medium text-muted-foreground">CC:</label>
<Input
type="text"
placeholder="Email addresses separated by comma"
v-model="cc"
class="flex-grow px-3 py-2 text-sm border rounded-md focus:ring-2 focus:ring-ring"
@blur="validateEmails('cc')"
/>
<Button
size="sm"
@click="hideBcc"
class="text-sm bg-secondary text-secondary-foreground hover:bg-secondary/80"
>
{{ showBcc ? 'Remove BCC' : 'BCC' }}
</Button>
</div>
<div v-if="showBcc" class="flex items-center space-x-2">
<label class="w-12 text-sm font-medium text-muted-foreground">BCC:</label>
<Input
type="text"
placeholder="Email addresses separated by comma"
v-model="bcc"
class="flex-grow px-3 py-2 text-sm border rounded-md focus:ring-2 focus:ring-ring"
@blur="validateEmails('bcc')"
/>
</div>
</div>
<div
v-if="emailErrors.length > 0"
class="mb-4 px-2 py-1 bg-destructive/10 border border-destructive text-destructive rounded"
>
<p v-for="error in emailErrors" :key="error" class="text-sm">{{ error }}</p>
</div>
<!-- Main Editor -->
<div class="flex-grow overflow-y-auto p-2">
<Editor
v-model:selectedText="selectedText"
v-model:isBold="isBold"
v-model:isItalic="isItalic"
v-model:htmlContent="htmlContent"
v-model:textContent="textContent"
:placeholder="editorPlaceholder"
:aiPrompts="aiPrompts"
@aiPromptSelected="handleAiPromptSelected"
:contentToSet="contentToSet"
@send="handleSend"
v-model:cursorPosition="cursorPosition"
:clearContent="clearEditorContent"
:setInlineImage="setInlineImage"
:insertContent="insertContent"
class="h-full"
/>
</div>
<!-- Macro preview -->
<MacroActionsPreview
v-if="conversationStore.conversation?.macro?.actions?.length > 0"
:actions="conversationStore.conversation.macro.actions"
:onRemove="conversationStore.removeMacroAction"
class="mt-4"
/>
<!-- Attachments preview -->
<AttachmentsPreview
:attachments="attachments"
:uploadingFiles="uploadingFiles"
:onDelete="handleOnFileDelete"
v-if="attachments.length > 0 || uploadingFiles.length > 0"
class="mt-4"
/>
<!-- Bottom menu bar -->
<ReplyBoxBottomMenuBar
class="mt-4 pt-4"
:handleFileUpload="handleFileUpload"
:handleInlineImageUpload="handleInlineImageUpload"
:isBold="isBold"
:isItalic="isItalic"
@toggleBold="toggleBold"
@toggleItalic="toggleItalic"
:enableSend="enableSend"
:handleSend="handleSend"
@emojiSelect="handleEmojiSelect"
/>
</div>
<ReplyBoxContent
v-if="isEditorFullscreen"
:isFullscreen="true"
:aiPrompts="aiPrompts"
:isSending="isSending"
:uploadingFiles="uploadingFiles"
:clearEditorContent="clearEditorContent"
:htmlContent="htmlContent"
:textContent="textContent"
:selectedText="selectedText"
:isBold="isBold"
:isItalic="isItalic"
:cursorPosition="cursorPosition"
:contentToSet="contentToSet"
:cc="cc"
:bcc="bcc"
:emailErrors="emailErrors"
:messageType="messageType"
:showBcc="showBcc"
@update:htmlContent="htmlContent = $event"
@update:textContent="textContent = $event"
@update:selectedText="selectedText = $event"
@update:isBold="isBold = $event"
@update:isItalic="isItalic = $event"
@update:cursorPosition="cursorPosition = $event"
@toggleFullscreen="isEditorFullscreen = false"
@update:messageType="messageType = $event"
@update:cc="cc = $event"
@update:bcc="bcc = $event"
@update:showBcc="showBcc = $event"
@updateEmailErrors="emailErrors = $event"
@send="processSend"
@fileUpload="handleFileUpload"
@inlineImageUpload="handleInlineImageUpload"
@fileDelete="handleOnFileDelete"
@aiPromptSelected="handleAiPromptSelected"
class="h-full flex-grow"
/>
</DialogContent>
</Dialog>
<!-- Main Editor non-fullscreen -->
<div class="bg-card text-card-foreground box px-2 pt-2 m-2">
<div v-if="!isEditorFullscreen" class="">
<!-- Message type toggle -->
<div class="flex justify-between items-center mb-4">
<Tabs v-model="messageType" class="rounded-lg">
<TabsList class="bg-muted p-1 rounded-lg">
<TabsTrigger
value="reply"
class="px-3 py-1 rounded-lg transition-colors duration-200"
:class="{ 'bg-background text-foreground': messageType === 'reply' }"
>
Reply
</TabsTrigger>
<TabsTrigger
value="private_note"
class="px-3 py-1 rounded-lg transition-colors duration-200"
:class="{ 'bg-background text-foreground': messageType === 'private_note' }"
>
Private note
</TabsTrigger>
</TabsList>
</Tabs>
<span
class="text-muted-foreground hover:text-foreground transition-colors duration-200 cursor-pointer mr-2"
variant="ghost"
@click="isEditorFullscreen = true"
>
<Maximize2 size="15" />
</span>
</div>
<div class="space-y-3 mb-4" v-if="messageType === 'reply'">
<div class="flex items-center space-x-2">
<label class="w-12 text-sm font-medium text-muted-foreground">CC:</label>
<Input
type="text"
placeholder="Email addresses separated by comma"
v-model="cc"
class="flex-grow px-3 py-2 text-sm border rounded-md focus:ring-2 focus:ring-ring"
@blur="validateEmails('cc')"
/>
<Button
size="sm"
@click="hideBcc"
class="text-sm bg-secondary text-secondary-foreground hover:bg-secondary/80"
>
{{ showBcc ? 'Remove BCC' : 'BCC' }}
</Button>
</div>
<div v-if="showBcc" class="flex items-center space-x-2">
<label class="w-12 text-sm font-medium text-muted-foreground">BCC:</label>
<Input
type="text"
placeholder="Email addresses separated by comma"
v-model="bcc"
class="flex-grow px-3 py-2 text-sm border rounded-md focus:ring-2 focus:ring-ring"
@blur="validateEmails('bcc')"
/>
</div>
</div>
<div
v-if="emailErrors.length > 0"
class="mb-4 px-2 py-1 bg-destructive/10 border border-destructive text-destructive rounded"
>
<p v-for="error in emailErrors" :key="error" class="text-sm">{{ error }}</p>
</div>
<!-- Main Editor -->
<Editor
v-model:selectedText="selectedText"
v-model:isBold="isBold"
v-model:isItalic="isItalic"
v-model:htmlContent="htmlContent"
v-model:textContent="textContent"
:placeholder="editorPlaceholder"
:aiPrompts="aiPrompts"
@aiPromptSelected="handleAiPromptSelected"
:contentToSet="contentToSet"
@send="handleSend"
v-model:cursorPosition="cursorPosition"
:clearContent="clearEditorContent"
:setInlineImage="setInlineImage"
:insertContent="insertContent"
/>
<!-- Macro preview -->
<MacroActionsPreview
v-if="conversationStore.conversation?.macro?.actions?.length > 0"
:actions="conversationStore.conversation.macro.actions"
:onRemove="conversationStore.removeMacroAction"
/>
<!-- Attachments preview -->
<AttachmentsPreview
:attachments="attachments"
:uploadingFiles="uploadingFiles"
:onDelete="handleOnFileDelete"
v-if="attachments.length > 0 || uploadingFiles.length > 0"
class="mt-4"
/>
<!-- Bottom menu bar -->
<ReplyBoxBottomMenuBar
class="mt-1"
:handleFileUpload="handleFileUpload"
:handleInlineImageUpload="handleInlineImageUpload"
:isBold="isBold"
:isItalic="isItalic"
@toggleBold="toggleBold"
@toggleItalic="toggleItalic"
:enableSend="enableSend"
:handleSend="handleSend"
@emojiSelect="handleEmojiSelect"
/>
</div>
<div
class="bg-card text-card-foreground box m-2 px-2 pt-2 flex flex-col"
v-if="!isEditorFullscreen"
>
<ReplyBoxContent
:isFullscreen="false"
:aiPrompts="aiPrompts"
:isSending="isSending"
:uploadingFiles="uploadingFiles"
:clearEditorContent="clearEditorContent"
:htmlContent="htmlContent"
:textContent="textContent"
:selectedText="selectedText"
:isBold="isBold"
:isItalic="isItalic"
:cursorPosition="cursorPosition"
:contentToSet="contentToSet"
:cc="cc"
:bcc="bcc"
:emailErrors="emailErrors"
:messageType="messageType"
:showBcc="showBcc"
@update:htmlContent="htmlContent = $event"
@update:textContent="textContent = $event"
@update:selectedText="selectedText = $event"
@update:isBold="isBold = $event"
@update:isItalic="isItalic = $event"
@update:cursorPosition="cursorPosition = $event"
@toggleFullscreen="isEditorFullscreen = true"
@update:messageType="messageType = $event"
@update:cc="cc = $event"
@update:bcc="bcc = $event"
@update:showBcc="showBcc = $event"
@updateEmailErrors="emailErrors = $event"
@send="processSend"
@fileUpload="handleFileUpload"
@inlineImageUpload="handleInlineImageUpload"
@fileDelete="handleOnFileDelete"
@aiPromptSelected="handleAiPromptSelected"
/>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed, nextTick, watch } from 'vue'
import { ref, onMounted, nextTick, watch, computed } from 'vue'
import { transformImageSrcToCID } from '@/utils/strings'
import { handleHTTPError } from '@/utils/http'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { Maximize2, Minimize2 } from 'lucide-vue-next'
import { useUserStore } from '@/stores/user'
import api from '@/api'
import Editor from './ConversationTextEditor.vue'
import { useConversationStore } from '@/stores/conversation'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent } from '@/components/ui/dialog'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { useEmitter } from '@/composables/useEmitter'
import AttachmentsPreview from '@/features/conversation/message/attachment/AttachmentsPreview.vue'
import MacroActionsPreview from '@/features/conversation/MacroActionsPreview.vue'
import ReplyBoxBottomMenuBar from '@/features/conversation/ReplyBoxMenuBar.vue'
import ReplyBoxContent from '@/features/conversation/ReplyBoxContent.vue'
import {
Form,
FormField,
FormItem,
FormLabel,
FormControl,
FormMessage
} from '@/components/ui/form'
import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod'
const formSchema = toTypedSchema(
z.object({
apiKey: z.string().min(1, 'API key is required')
})
)
const conversationStore = useConversationStore()
const emitter = useEmitter()
const insertContent = ref(null)
const setInlineImage = ref(null)
const userStore = useUserStore()
const openAIKeyPrompt = ref(false)
const isOpenAIKeyUpdating = ref(false)
// Shared state between the two editor components.
const clearEditorContent = ref(false)
const isEditorFullscreen = ref(false)
const cursorPosition = ref(0)
const selectedText = ref('')
const htmlContent = ref('')
const textContent = ref('')
const contentToSet = ref('')
const isBold = ref(false)
const isItalic = ref(false)
const isSending = ref(false)
const messageType = ref('reply')
const showBcc = ref(false)
const cc = ref('')
const bcc = ref('')
const showBcc = ref(false)
const emailErrors = ref([])
const aiPrompts = ref([])
const uploadingFiles = ref([])
const editorPlaceholder = 'Press Enter to add a new line; Press Ctrl + Enter to send.'
const htmlContent = ref('')
const textContent = ref('')
const selectedText = ref('')
const isBold = ref(false)
const isItalic = ref(false)
const cursorPosition = ref(0)
const contentToSet = ref('')
onMounted(async () => {
await fetchAiPrompts()
})
const hideBcc = () => {
showBcc.value = !showBcc.value
}
watch(
() => conversationStore.currentCC,
(newVal) => {
cc.value = newVal?.join(', ') || ''
},
{ deep: true, immediate: true }
)
watch(
() => conversationStore.currentBCC,
(newVal) => {
const newBcc = newVal?.join(', ') || ''
bcc.value = newBcc
if (newBcc.length == 0) {
showBcc.value = false
} else {
showBcc.value = true
}
},
{ deep: true, immediate: true }
)
/**
* Fetches AI prompts from the server.
*/
const fetchAiPrompts = async () => {
try {
const resp = await api.getAiPrompts()
@@ -335,14 +210,27 @@ const fetchAiPrompts = async () => {
}
}
/**
* Handles the AI prompt selection event.
* Sends the selected prompt key and the current text content to the server for completion.
* Sets the response as the new content in the editor.
* @param {String} key - The key of the selected AI prompt
*/
const handleAiPromptSelected = async (key) => {
try {
const resp = await api.aiCompletion({
prompt_key: key,
content: selectedText.value
content: textContent.value
})
contentToSet.value = JSON.stringify({
content: resp.data.data.replace(/\n/g, '<br>'),
timestamp: Date.now()
})
contentToSet.value = resp.data.data.replace(/\n/g, '<br>')
} catch (error) {
// Check if user needs to enter OpenAI API key and has permission to do so.
if (error.response?.status === 400 && userStore.can('ai:manage')) {
openAIKeyPrompt.value = true
}
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
@@ -351,33 +239,35 @@ const handleAiPromptSelected = async (key) => {
}
}
const toggleBold = () => {
isBold.value = !isBold.value
/**
* updateProvider updates the OpenAI API key.
* @param {Object} values - The form values containing the API key
*/
const updateProvider = async (values) => {
try {
isOpenAIKeyUpdating.value = true
await api.updateAIProvider({ api_key: values.apiKey, provider: 'openai' })
openAIKeyPrompt.value = false
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Success',
description: 'API key saved successfully.'
})
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})
} finally {
isOpenAIKeyUpdating.value = false
}
}
const toggleItalic = () => {
isItalic.value = !isItalic.value
}
const attachments = computed(() => {
return conversationStore.conversation.mediaFiles.filter(
(upload) => upload.disposition === 'attachment'
)
})
const enableSend = computed(() => {
return (
(textContent.value.trim().length > 0 ||
conversationStore.conversation?.macro?.actions?.length > 0) &&
emailErrors.value.length === 0 &&
!uploadingFiles.value.length
)
})
const hasTextContent = computed(() => {
return textContent.value.trim().length > 0
})
/**
* Handles the file upload process when files are selected.
* Uploads each file to the server and adds them to the conversation's mediaFiles.
* @param {Event} event - The file input change event containing selected files
*/
const handleFileUpload = (event) => {
const files = Array.from(event.target.files)
uploadingFiles.value = files
@@ -404,6 +294,7 @@ const handleFileUpload = (event) => {
}
}
// Inline image upload is not supported yet.
const handleInlineImageUpload = (event) => {
for (const file of event.target.files) {
api
@@ -413,12 +304,13 @@ const handleInlineImageUpload = (event) => {
linked_model: 'messages'
})
.then((resp) => {
setInlineImage.value = {
const imageData = {
src: resp.data.data.url,
alt: resp.data.data.filename,
title: resp.data.data.uuid
}
conversationStore.conversation.mediaFiles.push(resp.data.data)
return imageData
})
.catch((error) => {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
@@ -430,42 +322,24 @@ const handleInlineImageUpload = (event) => {
}
}
const validateEmails = (field) => {
const emails = field === 'cc' ? cc.value : bcc.value
const emailList = emails
.split(',')
.map((e) => e.trim())
.filter((e) => e !== '')
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const invalidEmails = emailList.filter((email) => !emailRegex.test(email))
// Remove any existing errors for this field
emailErrors.value = emailErrors.value.filter(
(error) => !error.startsWith(`Invalid email(s) in ${field.toUpperCase()}`)
)
// Add new error if there are invalid emails
if (invalidEmails.length > 0) {
emailErrors.value.push(
`Invalid email(s) in ${field.toUpperCase()}: ${invalidEmails.join(', ')}`
)
}
}
const handleSend = async () => {
if (emailErrors.value.length > 0) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: 'Please correct the email errors before sending.'
})
return
}
/**
* Returns true if the editor has text content.
*/
const hasTextContent = computed(() => {
return textContent.value.trim().length > 0
})
/**
* Processes the send action.
*/
const processSend = async () => {
let hasAPIErrored = false
isEditorFullscreen.value = false
try {
isSending.value = true
// Send message if there is text content in the editor.
if (hasTextContent.value) {
if (hasTextContent.value > 0) {
// Replace inline image url with cid.
const message = transformImageSrcToCID(htmlContent.value)
@@ -493,7 +367,7 @@ const handleSend = async () => {
.split(',')
.map((email) => email.trim())
.filter((email) => email),
bcc: showBcc.value
bcc: bcc.value
? bcc.value
.split(',')
.map((email) => email.trim())
@@ -502,56 +376,101 @@ const handleSend = async () => {
})
}
// Apply macro if it exists.
// Apply macro actions if any.
// For macros errors just show toast and clear the editor, as most likely it's the permission error.
if (conversationStore.conversation?.macro?.actions?.length > 0) {
await api.applyMacro(
conversationStore.current.uuid,
conversationStore.conversation.macro.id,
conversationStore.conversation.macro.actions
)
try {
await api.applyMacro(
conversationStore.current.uuid,
conversationStore.conversation.macro.id,
conversationStore.conversation.macro.actions
)
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})
}
}
} catch (error) {
hasAPIErrored = true
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})
} finally {
clearEditorContent.value = true
conversationStore.resetMacro()
conversationStore.resetMediaFiles()
emailErrors.value = []
nextTick(() => {
clearEditorContent.value = false
})
// If API has NOT errored clear state.
if (hasAPIErrored === false) {
// Clear editor.
clearEditorContent.value = true
// Clear macro.
conversationStore.resetMacro()
// Clear media files.
conversationStore.resetMediaFiles()
// Clear any email errors.
emailErrors.value = []
nextTick(() => {
clearEditorContent.value = false
})
}
isSending.value = false
}
// Update assignee last seen timestamp.
api.updateAssigneeLastSeen(conversationStore.current.uuid)
}
/**
* Handles the file delete event.
* Removes the file from the conversation's mediaFiles.
* @param {String} uuid - The UUID of the file to delete
*/
const handleOnFileDelete = (uuid) => {
conversationStore.conversation.mediaFiles = conversationStore.conversation.mediaFiles.filter(
(item) => item.uuid !== uuid
)
}
const handleEmojiSelect = (emoji) => {
insertContent.value = undefined
// Force reactivity so the user can select the same emoji multiple times
nextTick(() => (insertContent.value = emoji))
}
// Watch for changes in macro content and update editor content.
/**
* Watches for changes in the conversation's macro id and update message content.
*/
watch(
() => conversationStore.conversation.macro,
() => conversationStore.conversation.macro.id,
() => {
// hack: Quill editor adds <p><br></p> replace with <p></p>
if (conversationStore.conversation?.macro?.message_content) {
contentToSet.value = conversationStore.conversation.macro.message_content.replace(
/<p><br><\/p>/g,
'<p></p>'
)
}
// Setting timestamp, so the same macro can be set again.
contentToSet.value = JSON.stringify({
content: conversationStore.conversation.macro.message_content,
timestamp: Date.now()
})
},
{ deep: true }
)
// Initialize cc and bcc from conversation store
watch(
() => conversationStore.currentCC,
(newVal) => {
cc.value = newVal?.join(', ') || ''
},
{ deep: true, immediate: true }
)
watch(
() => conversationStore.currentBCC,
(newVal) => {
const newBcc = newVal?.join(', ') || ''
bcc.value = newBcc
// Only show BCC field if it has content
if (newBcc.length > 0) {
showBcc.value = true
}
},
{ deep: true, immediate: true }
)
</script>

View File

@@ -0,0 +1,307 @@
<template>
<!-- Set fixed width only when not in fullscreen. -->
<div class="flex flex-col h-full" :class="{ 'max-h-[600px]': !isFullscreen }">
<!-- Message type toggle -->
<div
class="flex justify-between items-center"
:class="{ 'mb-4': !isFullscreen, 'border-b border-border pb-4': isFullscreen }"
>
<Tabs v-model="messageType" class="rounded-lg">
<TabsList class="bg-muted p-1 rounded-lg">
<TabsTrigger
value="reply"
class="px-3 py-1 rounded-lg transition-colors duration-200"
:class="{ 'bg-background text-foreground': messageType === 'reply' }"
>
Reply
</TabsTrigger>
<TabsTrigger
value="private_note"
class="px-3 py-1 rounded-lg transition-colors duration-200"
:class="{ 'bg-background text-foreground': messageType === 'private_note' }"
>
Private note
</TabsTrigger>
</TabsList>
</Tabs>
<span
class="text-muted-foreground hover:text-foreground transition-colors duration-200 cursor-pointer"
variant="ghost"
@click="toggleFullscreen"
>
<component
:is="isFullscreen ? Minimize2 : Maximize2"
:size="isFullscreen ? '18' : '15'"
:class="{ 'mr-2': !isFullscreen }"
/>
</span>
</div>
<!-- CC and BCC fields -->
<div
:class="['space-y-3', isFullscreen ? 'p-4 border-b border-border' : 'mb-4']"
v-if="messageType === 'reply'"
>
<div class="flex items-center space-x-2">
<label class="w-12 text-sm font-medium text-muted-foreground">CC:</label>
<Input
type="text"
placeholder="Email addresses separated by comma"
v-model="cc"
class="flex-grow px-3 py-2 text-sm border rounded-md focus:ring-2 focus:ring-ring"
@blur="validateEmails('cc')"
/>
<Button
size="sm"
@click="toggleBcc"
class="text-sm bg-secondary text-secondary-foreground hover:bg-secondary/80"
>
{{ showBcc ? 'Remove BCC' : 'BCC' }}
</Button>
</div>
<div v-if="showBcc" class="flex items-center space-x-2">
<label class="w-12 text-sm font-medium text-muted-foreground">BCC:</label>
<Input
type="text"
placeholder="Email addresses separated by comma"
v-model="bcc"
class="flex-grow px-3 py-2 text-sm border rounded-md focus:ring-2 focus:ring-ring"
@blur="validateEmails('bcc')"
/>
</div>
</div>
<!-- CC and BCC field validation errors -->
<div
v-if="emailErrors.length > 0"
class="mb-4 px-2 py-1 bg-destructive/10 border border-destructive text-destructive rounded"
>
<p v-for="error in emailErrors" :key="error" class="text-sm">{{ error }}</p>
</div>
<!-- Main tiptap editor -->
<div class="flex-grow flex flex-col overflow-hidden">
<Editor
v-model:selectedText="selectedText"
v-model:isBold="isBold"
v-model:isItalic="isItalic"
v-model:htmlContent="htmlContent"
v-model:textContent="textContent"
v-model:cursorPosition="cursorPosition"
:placeholder="editorPlaceholder"
:aiPrompts="aiPrompts"
@aiPromptSelected="handleAiPromptSelected"
:contentToSet="contentToSet"
@send="handleSend"
:clearContent="clearEditorContent"
:setInlineImage="setInlineImage"
:insertContent="insertContent"
/>
</div>
<!-- Macro preview -->
<MacroActionsPreview
v-if="conversationStore.conversation?.macro?.actions?.length > 0"
:actions="conversationStore.conversation.macro.actions"
:onRemove="conversationStore.removeMacroAction"
class="mt-2"
/>
<!-- Attachments preview -->
<AttachmentsPreview
:attachments="attachments"
:uploadingFiles="uploadingFiles"
:onDelete="handleOnFileDelete"
v-if="attachments.length > 0 || uploadingFiles.length > 0"
class="mt-2"
/>
<!-- Editor menu bar with send button -->
<ReplyBoxMenuBar
class="mt-1 shrink-0"
:handleFileUpload="handleFileUpload"
:handleInlineImageUpload="handleInlineImageUpload"
:isBold="isBold"
:isItalic="isItalic"
:isSending="isSending"
@toggleBold="toggleBold"
@toggleItalic="toggleItalic"
:enableSend="enableSend"
:handleSend="handleSend"
@emojiSelect="handleEmojiSelect"
/>
</div>
</template>
<script setup>
import { ref, computed, nextTick } from 'vue'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { Maximize2, Minimize2 } from 'lucide-vue-next'
import Editor from './ConversationTextEditor.vue'
import { useConversationStore } from '@/stores/conversation'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useEmitter } from '@/composables/useEmitter'
import AttachmentsPreview from '@/features/conversation/message/attachment/AttachmentsPreview.vue'
import MacroActionsPreview from '@/features/conversation/MacroActionsPreview.vue'
import ReplyBoxMenuBar from '@/features/conversation/ReplyBoxMenuBar.vue'
// Define models for two-way binding
const messageType = defineModel('messageType', { default: 'reply' })
const cc = defineModel('cc', { default: '' })
const bcc = defineModel('bcc', { default: '' })
const showBcc = defineModel('showBcc', { default: false })
const emailErrors = defineModel('emailErrors', { default: () => [] })
const htmlContent = defineModel('htmlContent', { default: '' })
const textContent = defineModel('textContent', { default: '' })
const selectedText = defineModel('selectedText', { default: '' })
const isBold = defineModel('isBold', { default: false })
const isItalic = defineModel('isItalic', { default: false })
const cursorPosition = defineModel('cursorPosition', { default: 0 })
const props = defineProps({
isFullscreen: {
type: Boolean,
default: false
},
aiPrompts: {
type: Array,
required: true
},
isSending: {
type: Boolean,
required: true
},
uploadingFiles: {
type: Array,
required: true
},
clearEditorContent: {
type: Boolean,
required: true
},
contentToSet: {
type: String,
default: null
}
})
const emit = defineEmits([
'toggleFullscreen',
'send',
'fileUpload',
'inlineImageUpload',
'fileDelete',
'aiPromptSelected'
])
const conversationStore = useConversationStore()
const emitter = useEmitter()
const insertContent = ref(null)
const setInlineImage = ref(null)
const editorPlaceholder =
'Shift + Enter to add a new line. Cmd + Enter to send. Cmd + K to open command bar.'
const toggleBcc = async () => {
showBcc.value = !showBcc.value
await nextTick()
// If hiding BCC field, clear the content
if (!showBcc.value) {
bcc.value = ''
}
}
const toggleFullscreen = () => {
emit('toggleFullscreen')
}
const toggleBold = () => {
isBold.value = !isBold.value
}
const toggleItalic = () => {
isItalic.value = !isItalic.value
}
const attachments = computed(() => {
return conversationStore.conversation.mediaFiles.filter(
(upload) => upload.disposition === 'attachment'
)
})
const enableSend = computed(() => {
return (
(textContent.value.trim().length > 0 ||
conversationStore.conversation?.macro?.actions?.length > 0) &&
emailErrors.value.length === 0 &&
!props.uploadingFiles.length
)
})
/**
* Validate email addresses in the CC and BCC fields
* @param {string} field - 'cc' or 'bcc'
*/
const validateEmails = (field) => {
const emails = field === 'cc' ? cc.value : bcc.value
const emailList = emails
.split(',')
.map((e) => e.trim())
.filter((e) => e !== '')
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const invalidEmails = emailList.filter((email) => !emailRegex.test(email))
// Remove any existing errors for this field
emailErrors.value = emailErrors.value.filter(
(error) => !error.startsWith(`Invalid email(s) in ${field.toUpperCase()}`)
)
// Add new error if there are invalid emails
if (invalidEmails.length > 0) {
emailErrors.value.push(
`Invalid email(s) in ${field.toUpperCase()}: ${invalidEmails.join(', ')}`
)
}
}
/**
* Send the reply or private note
*/
const handleSend = async () => {
validateEmails('cc')
validateEmails('bcc')
if (emailErrors.value.length > 0) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: 'Please correct the email errors before sending.'
})
return
}
emit('send')
}
const handleFileUpload = (event) => {
emit('fileUpload', event)
}
const handleInlineImageUpload = (event) => {
emit('inlineImageUpload', event)
}
const handleOnFileDelete = (uuid) => {
emit('fileDelete', uuid)
}
const handleEmojiSelect = (emoji) => {
insertContent.value = undefined
// Force reactivity so the user can select the same emoji multiple times
nextTick(() => (insertContent.value = emoji))
}
const handleAiPromptSelected = (key) => {
emit('aiPromptSelected', key)
}
</script>

View File

@@ -35,7 +35,9 @@
<Smile class="h-4 w-4" />
</Toggle>
</div>
<Button class="h-8 w-6 px-8" @click="handleSend" :disabled="!enableSend">Send</Button>
<Button class="h-8 w-6 px-8" @click="handleSend" :disabled="!enableSend" :isLoading="isSending"
>Send</Button
>
</div>
</template>
@@ -52,11 +54,11 @@ const attachmentInput = ref(null)
const inlineImageInput = ref(null)
const isEmojiPickerVisible = ref(false)
const emojiPickerRef = ref(null)
const emit = defineEmits(['toggleBold', 'toggleItalic', 'emojiSelect'])
const emit = defineEmits(['emojiSelect'])
// Using defineProps for props that don't need two-way binding
defineProps({
isBold: Boolean,
isItalic: Boolean,
isSending: Boolean,
enableSend: Boolean,
handleSend: Function,
handleFileUpload: Function,
@@ -68,7 +70,11 @@ onClickOutside(emojiPickerRef, () => {
})
const triggerFileUpload = () => {
attachmentInput.value.click()
if (attachmentInput.value) {
// Clear the value to allow the same file to be uploaded again.
attachmentInput.value.value = ''
attachmentInput.value.click()
}
}
const toggleEmojiPicker = () => {

View File

@@ -1,16 +1,15 @@
<template>
<div class="h-screen flex flex-col">
<!-- Header -->
<header class="border-b">
<div class="flex items-center space-x-4 p-2">
<SidebarTrigger class="h-4 w-4" />
<span class="text-xl font-semibold text-gray-800">{{ title }}</span>
</div>
</header>
<div class="flex items-center space-x-4 px-2 h-12 border-b shrink-0">
<SidebarTrigger class="h-4 w-4" />
<span class="text-xl font-semibold text-gray-800">{{ title }}</span>
</div>
<!-- Filters -->
<div class="bg-white px-2 py-2 flex justify-between items-center">
<DropdownMenu>
<div class="bg-white p-2 flex justify-between items-center">
<!-- Status dropdown-menu, hidden when a view is selected as views are pre-filtered -->
<DropdownMenu v-if="!route.params.viewID">
<DropdownMenuTrigger asChild>
<Button variant="ghost" class="w-30">
<div>
@@ -30,6 +29,9 @@
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div v-else></div>
<!-- Sort dropdown-menu -->
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" class="w-30">
@@ -107,7 +109,7 @@
<!-- Loading Skeleton -->
<div v-if="isLoading" key="loading" class="space-y-4">
<ConversationListItemSkeleton v-for="index in 10" :key="index" />
<ConversationListItemSkeleton v-for="index in 5" :key="index" />
</div>
</TransitionGroup>
@@ -126,7 +128,12 @@
<Loader2 v-if="isLoading" class="mr-2 h-4 w-4 animate-spin" />
{{ isLoading ? 'Loading...' : 'Load more' }}
</Button>
<p v-else class="text-sm text-gray-500">All conversations loaded</p>
<p
class="text-sm text-gray-500"
v-else-if="conversationStore.conversationsList.length > 10"
>
All conversations loaded
</p>
</div>
</div>
</div>

View File

@@ -39,10 +39,14 @@
<!-- Message preview and unread count -->
<div class="flex items-start justify-between gap-2">
<p class="text-sm text-gray-600 line-clamp-2 flex-1">
<Reply class="inline-block w-4 h-4 mr-1.5 text-green-600 flex-shrink-0" />
<div class="text-sm text-gray-600 flex items-center gap-1.5 flex-1 break-all">
<Reply
class="text-green-600 flex-shrink-0"
size="15"
v-if="conversation.last_message_sender === 'agent'"
/>
{{ trimmedLastMessage }}
</p>
</div>
<div
v-if="conversation.unread_message_count > 0"
class="flex items-center justify-center w-6 h-6 bg-green-600 text-white text-xs font-medium rounded-full"
@@ -53,16 +57,18 @@
<div class="flex items-center mt-2 space-x-2">
<SlaBadge
v-if="conversation.first_response_due_at"
:dueAt="conversation.first_response_due_at"
:actualAt="conversation.first_reply_at"
:label="'FRD'"
:showSLAMet="false"
:showExtra="false"
/>
<SlaBadge
v-if="conversation.resolution_due_at"
:dueAt="conversation.resolution_due_at"
:actualAt="conversation.resolved_at"
:label="'RD'"
:showSLAMet="false"
:showExtra="false"
/>
</div>
</div>

View File

@@ -19,18 +19,22 @@
}"
>
<!-- Message Content -->
<div v-html="messageContent" :class="{ 'mb-3': message.attachments.length > 0 }"></div>
<div
v-dompurify-html="messageContent"
class="whitespace-pre-wrap break-words overflow-wrap-anywhere native-html"
:class="{ 'mb-3': message.attachments.length > 0 }"
/>
<!-- Attachments -->
<MessageAttachmentPreview :attachments="nonInlineAttachments" />
<!-- Spinner for Pending Messages -->
<Spinner v-if="message.status === 'pending'" size="w-4 h-4" class="mt-2" />
<Spinner v-if="message.status === 'pending'" size="w-4 h-4" />
<!-- Icons -->
<div class="flex items-center space-x-2 mt-2">
<Lock :size="10" v-if="isPrivateMessage" class="text-muted-foreground" />
<CheckCheck :size="14" v-if="showCheckCheck" class="text-green-500" />
<Check :size="14" v-if="showCheckCheck" class="text-green-500" />
<RotateCcw
size="10"
@click="retryMessage(message)"
@@ -69,7 +73,7 @@
import { computed } from 'vue'
import { format } from 'date-fns'
import { useConversationStore } from '@/stores/conversation'
import { Lock, RotateCcw, CheckCheck } from 'lucide-vue-next'
import { Lock, RotateCcw, Check } from 'lucide-vue-next'
import { revertCIDToImageSrc } from '@/utils/strings'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { Spinner } from '@/components/ui/spinner'
@@ -125,3 +129,9 @@ const retryMessage = (msg) => {
api.retryMessage(convStore.current.uuid, msg.uuid)
}
</script>
<style scoped>
.overflow-wrap-anywhere {
overflow-wrap: anywhere;
}
</style>

View File

@@ -29,7 +29,7 @@
<Letter
:html="sanitizedMessageContent"
:allowedSchemas="['cid', 'https', 'http']"
class="mb-1"
class="mb-1 native-html"
:class="{ 'mb-3': message.attachments.length > 0 }"
/>

View File

@@ -1,7 +1,7 @@
<template>
<div class="flex flex-col relative h-full">
<div ref="threadEl" class="flex-1 overflow-y-auto" @scroll="handleScroll">
<div class="min-h-full pb-20 px-4">
<div class="min-h-full px-4 pb-10">
<div
class="text-center mt-3"
v-if="
@@ -20,16 +20,16 @@
</Button>
</div>
<TransitionGroup
enter-active-class="animate-slide-in"
tag="div"
class="space-y-4"
v-if="!conversationStore.messages.loading"
>
<MessagesSkeleton :count="10" v-if="conversationStore.messages.loading" />
<TransitionGroup v-else enter-active-class="animate-slide-in" tag="div" class="space-y-4">
<div
v-for="message in conversationStore.conversationMessages"
v-for="(message, index) in conversationStore.conversationMessages"
:key="message.uuid"
:class="message.type === 'activity' ? 'my-2' : 'my-4'"
:class="{
'my-2': message.type === 'activity',
'pt-4': index === 0
}"
>
<div v-if="!message.private">
<ContactMessageBubble :message="message" v-if="message.type === 'incoming'" />
@@ -43,7 +43,6 @@
</div>
</div>
</TransitionGroup>
<MessagesSkeleton :count="20" v-else />
</div>
</div>
@@ -56,7 +55,7 @@
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 translate-y-1"
>
<div v-show="!isAtBottom" class="absolute bottom-12 right-6 z-10">
<div v-show="!isAtBottom" class="absolute bottom-5 right-6 z-10">
<button
@click="handleScrollToBottom"
class="w-10 h-10 rounded-full flex items-center justify-center shadow-lg border bg-white text-primary transition-colors duration-200 hover:bg-gray-100"

View File

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

View File

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

View File

@@ -27,8 +27,10 @@
<div class="flex justify-start items-center space-x-2">
<p class="font-medium">First reply at</p>
<SlaBadge
v-if="conversation.first_response_due_at"
:dueAt="conversation.first_response_due_at"
:actualAt="conversation.first_reply_at"
:key="conversation.uuid"
/>
</div>
<Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4" />
@@ -43,7 +45,12 @@
<div class="flex flex-col gap-1 mb-5">
<div class="flex justify-start items-center space-x-2">
<p class="font-medium">Resolved at</p>
<SlaBadge :dueAt="conversation.resolution_due_at" :actualAt="conversation.resolved_at" />
<SlaBadge
v-if="conversation.resolution_due_at"
:dueAt="conversation.resolution_due_at"
:actualAt="conversation.resolved_at"
:key="conversation.uuid"
/>
</div>
<Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4" />
<div v-else>

View File

@@ -6,7 +6,7 @@
collapsible
:default-value="['Actions', 'Information', 'Previous conversations']"
>
<AccordionItem value="Actions" class="border-0 mb-2 mb-2">
<AccordionItem value="Actions" class="border-0 mb-2">
<AccordionTrigger class="bg-muted px-4 py-3 text-sm font-medium rounded-lg mx-2">
Actions
</AccordionTrigger>

View File

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

View File

@@ -1,25 +1,36 @@
<template>
<div class="flex flex-1 flex-col gap-x-5 box p-5 space-y-5 bg-white">
<div class="flex items-center space-x-2">
<p class="text-2xl">{{ title }}</p>
<p class="text-2xl flex items-center">{{ title }}</p>
<div class="bg-green-100/70 flex items-center space-x-2 px-1 rounded">
<span class="blinking-dot"></span>
<p class="uppercase text-xs">Live</p>
</div>
</div>
<div class="flex justify-between pr-32">
<div v-for="(value, key) in counts" :key="key" class="flex flex-col items-center gap-y-2">
<div
v-for="(item, key) in filteredCounts"
:key="key"
class="flex flex-col items-center gap-y-2"
>
<span class="text-muted-foreground">{{ labels[key] }}</span>
<span class="text-2xl font-medium">{{ value }}</span>
<span class="text-2xl font-medium">{{ item }}</span>
</div>
</div>
</div>
</template>
<script setup>
defineProps({
import { computed } from 'vue'
const props = defineProps({
counts: { type: Object, required: true },
labels: { type: Object, required: true },
title: { type: String, required: true }
})
// Filter out counts that don't have a label
const filteredCounts = computed(() => {
return Object.fromEntries(Object.entries(props.counts).filter(([key]) => props.labels[key]))
})
</script>

View File

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

View File

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

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