Compare commits

...

219 Commits

Author SHA1 Message Date
Abhinav Raut
afeec39b59 make subject required, submit new conversation form on ctrl + enter 2025-06-03 23:03:06 +05:30
Abhinav Raut
fb2a08ec1a fix: SelectCombobox adjust item and selected templates for proper emoji rendering 2025-06-03 22:36:49 +05:30
Abhinav Raut
7f2df0082c fix: remove autofocus from text editor as create conversation form opens.
- Adds new prop autofocus on text editor
- rename ConversationTextEditor to TextEditor
2025-06-03 22:10:56 +05:30
Abhinav Raut
6c523ac447 fix: update labels and placeholders for user selection to agent in MacroForm 2025-06-03 05:19:13 +05:30
Abhinav Raut
02fc57c35a macro form fixes 2025-06-03 05:08:41 +05:30
Abhinav Raut
cd0a357695 fix: change order of macros selection to updated_at descending 2025-06-03 04:41:24 +05:30
Abhinav Raut
2dc751e602 fixes incorrect v-model binding 2025-06-03 04:29:28 +05:30
Abhinav Raut
8bc0cce993 fix: update default values for visible_when column in macros table 2025-06-03 04:08:29 +05:30
Abhinav Raut
f6e2fc1956 feat: allow sending attachments in new conversations
- replace existing combobox selects with common component selectcombobox.vue
2025-06-03 04:03:16 +05:30
Abhinav Raut
5fe5ac5882 fix: change order of macros selection to usage_count descending 2025-06-03 00:56:16 +05:30
Abhinav Raut
975577555d WIP: allow setting macro in new conversations along with attachments
- new composable useFileUpload.js
2025-06-02 03:56:04 +05:30
Abhinav Raut
f43acb77a1 use loader animation instead of dot loader in shadcn button 2025-05-31 23:46:08 +05:30
Abhinav Raut
331c84fa56 use dot loader to use tailwind animations 2025-05-31 23:40:42 +05:30
Abhinav Raut
9314efb9d9 refactor: clean up main.css move animation to tailwind config 2025-05-31 23:38:44 +05:30
Abhinav Raut
5c8481af97 feat: tooltips to icon side
refactor: remove unncessary extra i18n keys instead use reusable 'globals.terms.*' keys.
2025-05-31 20:11:47 +05:30
Abhinav Raut
d9bc4d1c0d fix: update conversation list item last message timestamp every 60 seconds 2025-05-31 18:54:08 +05:30
Abhinav Raut
087c8ad491 fix: incorrect label in macro form team select combobox 2025-05-31 18:35:48 +05:30
Abhinav Raut
65cac843cb fix: IMAP fetch blocking introduced by header fetching in this commit - 9a651702ce
Two separate fetches caused blocking when first fetch wasn't fully consumed.
Collect all envelope/header data first, then process to prevent deadlock.
2025-05-31 18:24:05 +05:30
Abhinav Raut
23b0481f24 update reference number format in conversation insert query, use - #number format instead of square bracket 2025-05-30 02:01:51 +05:30
Abhinav Raut
9a651702ce fix[imap]: skip auto reply email messages
Fixes #94
2025-05-30 02:00:18 +05:30
Abhinav Raut
a0203f882e fix: allow changing conversation status to resolved again & again as agent might change the snooze duration 2025-05-30 00:46:30 +05:30
Abhinav Raut
75425ca0dd Merge pull request #98 from abhinavxd/feat/dark-mode
feat: dark mode
2025-05-29 01:52:31 +05:30
Abhinav Raut
c2849fa63d fix views sidebar collapsible trigger 2025-05-29 01:48:29 +05:30
Abhinav Raut
b20c7845ac update sidebar icons for inbox navigation 2025-05-29 01:34:38 +05:30
Abhinav Raut
38a5b25b1f remove search icon from search header 2025-05-29 01:11:14 +05:30
Abhinav Raut
9dce155ebc fix: sidebar header spacing and ui improvements for search icon 2025-05-29 01:10:58 +05:30
Abhinav Raut
314341b40d fix: make both sections of macro preview 1. list and 2. preview scrollable separately.
- Update styles and colors for dark mode
2025-05-29 00:53:10 +05:30
Abhinav Raut
1f6e3322aa update sidebar background color and improve dark mode styles / colors
fix: email validation trigger in reply box
2025-05-29 00:52:16 +05:30
Abhinav Raut
102ba99b3c fix: toggle fullscreen state correctly in ReplyBox component 2025-05-29 00:39:14 +05:30
Abhinav Raut
8285575f1c update styles for convo list 2025-05-28 02:31:34 +05:30
Abhinav Raut
01d3b590a9 update sidebar foreground text color to improve contrast 2025-05-28 02:02:54 +05:30
Abhinav Raut
210e0de1ae feat: dark mode 2025-05-28 01:50:35 +05:30
Abhinav Raut
1f8fdf2ef6 Merge pull request #95 from abhinavxd/feat/sla-metric-next-response-time
Feature : Next response SLA metric
2025-05-27 03:19:52 +05:30
Abhinav Raut
696e4780ac refactor: reuse existing i18n keys for sla translations 2025-05-27 02:52:33 +05:30
Abhinav Raut
3998798e54 refactor: rename SQL query names and struct fields for clarity and consistency 2025-05-27 02:45:43 +05:30
Abhinav Raut
70b5da29e1 fix: change SLA deadline fields to use nullable types 2025-05-26 03:48:41 +05:30
Abhinav Raut
88ef5d26db fix: update sla timestamps to nullable types 2025-05-26 03:48:41 +05:30
Abhinav Raut
54bad59392 fix: getConversation to handle nullable UUID parameter 2025-05-26 03:48:41 +05:30
Abhinav Raut
506bb91e20 fix: make sla metric timestamps nullable 2025-05-26 03:48:41 +05:30
Abhinav Raut
d1478e1971 fix: clarify comment on SendNotification method regarding SLA linkage 2025-05-26 03:48:41 +05:30
Abhinav Raut
5583b472f7 fix: change debug logs to info level for scheduled SLA notifications 2025-05-26 03:48:41 +05:30
Abhinav Raut
b715483260 refactor(conversation): reduce DB I/O by using existing appliedSLAID from conversation
- Passes appliedSLAID directly to SLA logic instead of refetching
- Adds appliedSLAID field to conversation struct (already fetched in get-conversation query)
2025-05-26 03:48:41 +05:30
Abhinav Raut
8ce0464603 fix: simplify time_delay validation in SLA notification schema 2025-05-26 03:48:41 +05:30
Abhinav Raut
a84ed1ed32 Allow setting any value for SLA delay duration, replace select with input text
Validations to delay duration
2025-05-26 03:48:41 +05:30
Abhinav Raut
7426a09478 feat: allow setting metric per SLA notification, so admins can set SLA alert per metric or just set to all if they want a notification to be sent for all metrics
- Make sla time fields (first response, next response, resolution) optional, only 1 field is required.
2025-05-26 03:48:41 +05:30
Abhinav Raut
8ad2f078ac fix sql query 2025-05-26 03:48:41 +05:30
Abhinav Raut
9226063db3 fix: remove queries using conversation.applied_sla_id
as this column is removed
- fix sql query
2025-05-26 03:48:41 +05:30
Abhinav Raut
a9fd4fe2b6 fix: uise existing set next sla deadline sql query and remove duplicate query.
- remove previously added `applied_sla_id` column to conversations table as it was causing cyclic dep
2025-05-26 03:48:41 +05:30
Abhinav Raut
7e8c9962c3 Fixes for next response time sla metric 2025-05-26 03:48:41 +05:30
Abhinav Raut
cf20142e40 fix(sla-badge): emit SLA status on change so callers can react 2025-05-26 03:48:41 +05:30
Abhinav Raut
8654a04dcf fix: make sure sla badges re-render on timestamp changes, use a composite :key 2025-05-26 03:48:41 +05:30
Abhinav Raut
4c766d8ccb wip: next response metric for sla 2025-05-26 03:48:41 +05:30
Abhinav Raut
cb1ec7eb8e fix(availability-status): prevent 'away_and_reassigning' and away_manual agents from being set to 'offline' due to incorrect SQL 2025-05-24 19:42:41 +05:30
Abhinav Raut
a89c3dbe04 fix(agent-availability): skip activity log creation when agent returns online from inactivity as it can spam activity logs. 2025-05-20 00:53:28 +05:30
Abhinav Raut
e2319714ca fix(imap-email): lowercase all envelope email addresses for consistent matching and deduplication 2025-05-20 00:02:15 +05:30
Abhinav Raut
172f78262e docs: fix typo 2025-05-18 22:06:59 +05:30
Abhinav Raut
f53d5f188f docs: update Nginx configuration for client IP and set max body size to 100MB, remove bold styling from sso headings. 2025-05-18 22:04:13 +05:30
Abhinav Raut
55ec962003 fix(activity-log): replace RemoteIP with fast-realip pkg for accurate IP retrieval
- Update go version to 1.24.3
2025-05-18 21:48:15 +05:30
Abhinav Raut
d3b1955cb2 fix(activity-log): update activity type labels from 'User' to 'Agent' 2025-05-18 21:48:15 +05:30
Abhinav Raut
fac496fef2 Update README.md 2025-05-18 13:27:42 +05:30
Abhinav Raut
c36a425a1e Update README.md
Add activity log feature description to README
2025-05-18 13:27:07 +05:30
Abhinav Raut
f43ab5041e feat(auth): record login time and insert activity log for OIDC login 2025-05-18 12:06:09 +05:30
Abhinav Raut
cd0ff1b67d Revert: Add subject back to conversation sidebar as old conversations will not have subject in message meta, so the sidebar subject needs to be shown for now. 2025-05-17 21:54:39 +05:30
Abhinav Raut
5bc065469d Merge pull request #92 from abhinavxd/feat/activity/audit-log
Feature - Activity log / audit log
2025-05-17 21:23:18 +05:30
Abhinav Raut
77be86b1f4 chore: move features/filterbuilder.vue to components/filterbuilder.vue 2025-05-17 21:18:32 +05:30
Abhinav Raut
dde84c65b0 fix(activity-log): update header label from 'date' to 'timestamp' 2025-05-17 21:07:35 +05:30
Abhinav Raut
f2d4969733 fix(activity-log): remove unused Card import 2025-05-17 19:56:55 +05:30
Abhinav Raut
aeececd001 fix(activity-log): Improve loading state layout and set default items per page to 15 2025-05-17 19:56:28 +05:30
Abhinav Raut
fdeeda8bca fix(schema): Update admin role permissions to include activity logs manage permission 2025-05-17 19:31:31 +05:30
Abhinav Raut
45bae57183 remove unused import 2025-05-17 19:29:47 +05:30
Abhinav Raut
a345b2e322 fix(contact-list): use ArrowDownWideNarrow for consistent sort icon 2025-05-17 19:23:45 +05:30
Abhinav Raut
490aaedb48 fix: update activity log types to use agent prefixes for consistency 2025-05-16 23:11:22 +05:30
Abhinav Raut
87361e5cda fix: adjust padding in ActivityLog layout for consistent spacing 2025-05-16 23:01:24 +05:30
Abhinav Raut
c039d5a20f fix: refactor filter builder layout for improved responsiveness and do not clear state on unmount 2025-05-16 23:01:24 +05:30
Abhinav Raut
53f15a3a7e fix: set user availability status to online instead of offline when admin selects "active" in the user availability dropdown 2025-05-16 23:01:24 +05:30
Abhinav Raut
a397d3d3ea fix: lowercase empty message for simple table 2025-05-16 23:01:24 +05:30
Abhinav Raut
4ca123e6a1 fix: swapped target and actor, set them correctly 2025-05-16 23:01:24 +05:30
Abhinav Raut
7dd5abdda6 fix: swapped target and actor, set them correctly 2025-05-16 23:01:24 +05:30
Abhinav Raut
c16144a2bf fix: schema 2025-05-16 23:01:24 +05:30
Abhinav Raut
7f1c2c2f11 feat(wip): activity log / audit log
- single table stores acitivites against entities, actors, timestamps, ip addresses and activity description.
- admin page to view, sort and filter activity logs.
- new `activity_logs:manage` permission
2025-05-16 23:01:24 +05:30
Abhinav Raut
d8a681d17e reduce border radius from 0.75rem to 0.5rem 2025-05-16 23:01:07 +05:30
Abhinav Raut
f657a873bc Merge pull request #85 from abhinavxd/fix/email-channel-to-bcc-cc
Fix and Improve Email Recipients Handling in Conversations
2025-05-15 11:08:55 +05:30
Abhinav Raut
88e07c324d fix(useIdleDetection): debounce online status update to prevent duplicate calls 2025-05-12 21:59:06 +05:30
Abhinav Raut
6c9eca3d81 fix: do not computed bcc from latest message. 2025-05-11 20:26:33 +05:30
Abhinav Raut
07b185050e fix: empty recipients in automated replies
- Make recipients list from the latest message recipients for automated replies
2025-05-11 18:51:34 +05:30
Abhinav Raut
66886c34e5 hide conversation subject from sidebar as each message in thread shows the subject (envelope) 2025-05-11 14:40:47 +05:30
Abhinav Raut
0af7265178 refactor: remove unused GetToAddress function and related SQL query 2025-05-11 14:18:07 +05:30
Abhinav Raut
f722de2fe4 fix: handle empty to and from addresses in message meta,
- remove unncessary console log
2025-05-11 14:11:20 +05:30
Abhinav Raut
6b2be57049 fix: set correct recipients when a 3rd email is involved in conversation, link to thread discussing this - https://github.com/abhinavxd/libredesk/issues/74#issue-3021419913
refactor move recipient computation to /utils/email-recipients
2025-05-10 23:46:19 +05:30
Abhinav Raut
e1b2ec8a4b wip: fix to, bcc, cc handling
- allow agent to set the to address, adds a to address input in the reply box.
- show to, from, bcc and subject in each message
- always use email addresses from message meta instead of querying via get-to-address
- Reorder notification form fields.
- Refactors and adhoc fixes.
2025-05-09 04:30:30 +05:30
Abhinav Raut
8d47a7456d Merge pull request #76 from abhinavxd/dependabot/go_modules/github.com/go-jose/go-jose/v4-4.0.5
chore(deps): bump github.com/go-jose/go-jose/v4 from 4.0.2 to 4.0.5
2025-05-03 21:08:32 +05:30
dependabot[bot]
62023695a5 chore(deps): bump github.com/go-jose/go-jose/v4 from 4.0.2 to 4.0.5
Bumps [github.com/go-jose/go-jose/v4](https://github.com/go-jose/go-jose) from 4.0.2 to 4.0.5.
- [Release notes](https://github.com/go-jose/go-jose/releases)
- [Changelog](https://github.com/go-jose/go-jose/blob/main/CHANGELOG.md)
- [Commits](https://github.com/go-jose/go-jose/compare/v4.0.2...v4.0.5)

---
updated-dependencies:
- dependency-name: github.com/go-jose/go-jose/v4
  dependency-version: 4.0.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-03 15:34:59 +00:00
Abhinav Raut
a212ed4afb Merge pull request #77 from abhinavxd/dependabot/npm_and_yarn/frontend/vue-i18n-9.14.3
chore(deps): bump vue-i18n from 9.14.2 to 9.14.3 in /frontend
2025-05-03 21:04:19 +05:30
Abhinav Raut
8e6bea09fe Merge pull request #78 from abhinavxd/dependabot/npm_and_yarn/frontend/vite-5.4.18
chore(deps-dev): bump vite from 5.4.11 to 5.4.18 in /frontend
2025-05-03 21:04:09 +05:30
Abhinav Raut
71e2e3cd8a Merge pull request #80 from abhinavxd/dependabot/go_modules/github.com/redis/go-redis/v9-9.5.5
chore(deps): bump github.com/redis/go-redis/v9 from 9.5.4 to 9.5.5
2025-05-03 21:03:52 +05:30
dependabot[bot]
59f5084bec chore(deps): bump github.com/redis/go-redis/v9 from 9.5.4 to 9.5.5
Bumps [github.com/redis/go-redis/v9](https://github.com/redis/go-redis) from 9.5.4 to 9.5.5.
- [Release notes](https://github.com/redis/go-redis/releases)
- [Changelog](https://github.com/redis/go-redis/blob/master/CHANGELOG.md)
- [Commits](https://github.com/redis/go-redis/compare/v9.5.4...v9.5.5)

---
updated-dependencies:
- dependency-name: github.com/redis/go-redis/v9
  dependency-version: 9.5.5
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-30 06:58:01 +00:00
dependabot[bot]
87e1477811 chore(deps-dev): bump vite from 5.4.11 to 5.4.18 in /frontend
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.11 to 5.4.18.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.18/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.18/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 5.4.18
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-30 06:57:15 +00:00
dependabot[bot]
10d3da608c chore(deps): bump vue-i18n from 9.14.2 to 9.14.3 in /frontend
Bumps [vue-i18n](https://github.com/intlify/vue-i18n/tree/HEAD/packages/vue-i18n) from 9.14.2 to 9.14.3.
- [Release notes](https://github.com/intlify/vue-i18n/releases)
- [Changelog](https://github.com/intlify/vue-i18n/blob/v9.14.3/CHANGELOG.md)
- [Commits](https://github.com/intlify/vue-i18n/commits/v9.14.3/packages/vue-i18n)

---
updated-dependencies:
- dependency-name: vue-i18n
  dependency-version: 9.14.3
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-30 06:56:55 +00:00
Abhinav Raut
0de7c91641 Update README.md 2025-04-29 17:32:36 +05:30
Abhinav Raut
61ec075bd6 fix(editor): prevent sidebar collapse on Ctrl+B inside editor 2025-04-29 02:10:34 +05:30
Abhinav Raut
0b2c607cd3 fix(deps): update simples3 to v0.9.0 2025-04-28 23:48:24 +05:30
Abhinav Raut
0556318714 set sane defaults for email notification smtp settings 2025-04-28 01:49:12 +05:30
Abhinav Raut
7b35cf0abf fix: improve regex validation for go duration 2025-04-28 01:47:23 +05:30
Abhinav Raut
8619aa8e17 fix(conversation): reorder timestamps, the last reply timestamp in conversation info sidebar 2025-04-27 23:13:33 +05:30
Abhinav Raut
25db57805e feat(macros): Filter out macro actions from preview to which agent does not have permissions,
feat(permissions): Seperate our permissions into `src/constants/permissions.js`
2025-04-27 23:09:58 +05:30
Abhinav Raut
3b2d0d049f Merge pull request #69 from jleroy/feature/conversation-last-reply
Add new automations rules filters "Hours since first reply" and "Hours since last reply"
2025-04-17 02:02:00 +05:30
Jonathan Leroy
1c6d03a4c2 Merge branch 'main' into feature/conversation-last-reply 2025-04-16 21:47:54 +02:00
Abhinav Raut
062e0c39da fix[api]: allow all authenticated users to list business hours.
Replaced `perm(..., "business_hours:manage")` with `auth(...)` on
GET /api/v1/business-hours since listing hours isn’t a sensitive action and is required in the general settings tab
2025-04-17 01:16:38 +05:30
Abhinav Raut
67090fb052 fix(docker): restrict Redis port binding to local interface 2025-04-17 00:51:27 +05:30
Abhinav Raut
c434de130b Merge pull request #63 from MinecollYT/main
Update docker-compose.yml to enhance healthcheck
2025-04-17 00:30:23 +05:30
Abhinav Raut
4e4f07f2e8 Merge pull request #67 from jleroy/feature/improve-tags-actions
Add new actions "Add tags" and "Remove tags" for macros and automations
2025-04-16 23:29:13 +05:30
Abhinav Raut
19a507c88f fix: SLA badge showing hit status for overdue 2025-04-16 14:32:14 +05:30
Jonathan Leroy
ac61d43688 Add new automations rules filters "Hours since first reply" and "Hours
since last reply"
2025-04-15 20:40:40 +02:00
Abhinav Raut
7f8e3ccbbc fix[automation]: handle empty fieldType in the frontend breaking the automation view.
fix: Previously removed operators for Number type field.
Fixes #68
2025-04-15 21:31:19 +05:30
Jonathan Leroy
facce8bdad Add new actions "Add tags" and "Remove tags" for macros and automations 2025-04-15 13:55:58 +02:00
Abhinav Raut
8acad27b75 feat: display last name in notes list 2025-04-15 03:48:56 +05:30
Abhinav Raut
24fbe14804 fix: allow setting phone number and calling code to null in update contact query 2025-04-15 03:46:24 +05:30
Abhinav Raut
061677f2b0 fix: update phone number validation to allow shorter lengths 2025-04-15 03:45:54 +05:30
Abhinav Raut
450b609d47 fix: handle null string form value for update contact form 2025-04-15 03:45:45 +05:30
Abhinav Raut
971a433f3d fix: typo in contact permission 2025-04-15 03:32:53 +05:30
Abhinav Raut
220321bb8c Merge pull request #64 from abhinavxd/feat/custom-attributes-and-notes
feat: custom attributes and contact notes
2025-04-15 03:11:53 +05:30
Abhinav Raut
d5ba70667d feat: allow notes to be deleted only by the owner of note and any agent with role Admin 2025-04-15 02:47:52 +05:30
Abhinav Raut
a9f9d368b9 refactor: replace contacts:manage with with more granular permissions like contacts:read_all, contacts:read, contacts:block etc.
fix: filter out unknown permissions from role form while submitting
fix: correct strong password validation logic on reset
refactor: remove contact note update handler as it adds more complexity agents can simply delete note.
feat: new block contact api which required contacts:block perm
2025-04-15 02:23:47 +05:30
Abhinav Raut
2fc642c34e feat: allow using contact custom attributes in automation rules 2025-04-15 02:23:47 +05:30
Abhinav Raut
488f14e87c fix(migrations): create index for contact notes table if not exists 2025-04-15 02:23:47 +05:30
Abhinav Raut
3702a61d74 fix(migrations): create contact notes table query 2025-04-15 02:23:47 +05:30
Abhinav Raut
b01f6f812d fix[ui]: add border and padding to CardHeader in ContactNotes header 2025-04-15 02:23:47 +05:30
Abhinav Raut
a0c77bc12e feat: contact notes
refactor:  split code in internal/users/users.go into following files
internal/users/notes.go
internal/users/agent.go
internal/users/contact.go
2025-04-15 02:23:47 +05:30
Abhinav Raut
8bc511509c fix: passing conversation loading state to custom attributes component 2025-04-15 02:23:47 +05:30
Abhinav Raut
0254bab266 feat: regex hint 2025-04-15 02:23:47 +05:30
Abhinav Raut
91372f5339 fix: cypress test 2025-04-15 02:23:47 +05:30
Abhinav Raut
d69a8c58d1 feat: custom attributes for contacts and conversations 2025-04-15 02:23:47 +05:30
Abhinav Raut
4e893ef876 fix: refactor link styles in OIDC and Templates documentation to use a shared class 2025-04-14 23:50:37 +05:30
Abhinav Raut
5770188e4d fix: add "Learn more" link to OIDC and Templates documentation 2025-04-14 23:14:22 +05:30
Abhinav Raut
8bd7895ccf fix: update section headers for clarity in templating documentation 2025-04-14 23:06:32 +05:30
Abhinav Raut
e10bb45582 fix: improve clarity and formatting in SSO and templating documentation 2025-04-14 23:00:38 +05:30
Abhinav Raut
a397bc059b feat: add translations doc & improve sso and templating doc. 2025-04-14 22:44:07 +05:30
Abhinav Raut
4a305ff889 fix: adds missing automation operator less than, fixes #65 2025-04-14 00:09:34 +05:30
Abhinav Raut
616410c0a9 fix: add policy to SLA policy set activity message for clarity 2025-04-13 18:00:30 +05:30
Jonas Leiner
408e1fc142 Update docker-compose.yml
https://github.com/abhinavxd/libredesk/issues/62
2025-04-13 08:26:16 +02:00
Abhinav Raut
bc586fe775 feat: Use dynamic site name from app settings in document title and user login form. Fixes #49
Refactor: Use common AuthLayout for resetpassword, setpassword and use login view.
Fix: Accessibility fixes to all auth forms
2025-04-11 01:49:21 +05:30
Abhinav Raut
a49038f965 fix: editor layout in full screen mode 2025-04-11 00:45:28 +05:30
Abhinav Raut
4cfe0ccbd9 fix(crowdin): update branch watch comment to reflect correct branch 2025-04-11 00:20:30 +05:30
Abhinav Raut
acbb94447c refactor(contact): move ContactForm.vue and formSchema.js into features/contact/ for a better structure 2025-04-11 00:20:16 +05:30
Abhinav Raut
cd429b9751 fix: add frontend page handler for /contacts/ 2025-04-10 19:07:41 +05:30
Abhinav Raut
78d073c499 remove redundant error logging when user is not found 2025-04-10 19:07:26 +05:30
Abhinav Raut
8083ad93b4 Merge pull request #59 from abhinavxd/feat/manage-contacts
Feat - Manage contacts tab
2025-04-10 19:01:42 +05:30
Abhinav Raut
ad99dee544 bump frontend version 2025-04-10 18:57:25 +05:30
Abhinav Raut
a5eeb03f0d fix: standardize capitalization in english language translations 2025-04-10 18:51:26 +05:30
Abhinav Raut
c81f6496ea fix: center country emoji in phone number input field 2025-04-10 18:46:40 +05:30
Abhinav Raut
143a12e3c3 fix: send correct error msg when email is invalid
fix: trim space around email address
2025-04-10 18:29:04 +05:30
Abhinav Raut
e2d6a214c4 fix: console warnings for contact form 2025-04-10 18:25:47 +05:30
Abhinav Raut
4a3afc83a5 fix[shadcn]: change 'src' prop requirement to optional in AvatarImage component 2025-04-10 18:21:30 +05:30
Abhinav Raut
bb512d5ecd fix: Handle 'contact not found' error when checking if contact is blocked for IMAP email search. 2025-04-10 17:38:15 +05:30
Abhinav Raut
7957dbbd4a Merge pull request #58 from MinecollYT/main
Documentation enhacement (SSO, Variable-List)
2025-04-10 17:20:53 +05:30
Abhinav Raut
199778e771 Merge pull request #48 from jleroy/feature/crowdin-source-upload
Add Crowdin GitHub action
2025-04-10 17:19:59 +05:30
Abhinav Raut
b2a53b18d5 feat: manage contacts
- New permission `contacts:manage`
- Views for contact list and single contact view.
- Ability to block contacts that in inturn stops new messages from the contact.
- Make all DB transactions that run dynamically generated SQL query `readonly`
- Rename `/admin/teams/users` to `/admin/users/agents/` for consistency. (ref #50)
- Fix UI glitches for long emails (refs #54)
- Fix empty created_at date for agents admin table (refs #51)
- Migrations for v0.6.0
2025-04-10 04:13:11 +05:30
Abhinav Raut
576c678403 fix: validate OrderBy field in query builder, adds stricter check to only allow order by for the whitelisted fields 2025-04-10 03:40:24 +05:30
Abhinav Raut
9bfe014d1e WIP: manage contacts page 2025-04-09 16:52:25 +05:30
Jonas Leiner
1b536bdc69 Update variables.md
fixed wrong variable list, removed non-existing ones
2025-04-07 23:19:05 +02:00
Jonas Leiner
c02339f311 Update sso.md
finished keycloak guide
2025-04-07 23:03:53 +02:00
Jonas Leiner
1e7ab144b6 Create variables.md
list of some variables
2025-04-07 22:49:10 +02:00
Jonas Leiner
e998529827 Update mkdocs.yml
added variable site
2025-04-07 22:43:04 +02:00
Jonas Leiner
0a57a2724e Update upgrade.md
changed admonitions type to warning
2025-04-07 12:25:05 +02:00
Jonas Leiner
d2248d34c5 Update mkdocs.yml
adding "advanced configuration" tab and using it as a parent for "sso.md"
2025-04-07 11:58:05 +02:00
Jonas Leiner
33f2f67ba8 Create sso.md 2025-04-07 11:55:51 +02:00
Jonathan Leroy
7075ca214c Add a section to README regading project translation 2025-04-06 20:07:44 +02:00
Jonathan Leroy
e68325d609 Merge branch 'abhinavxd:main' into feature/crowdin-source-upload 2025-04-06 19:50:19 +02:00
Jonathan Leroy
2499df866f Add Crowdin GitHub action 2025-04-06 02:35:35 +02:00
Abhinav Raut
be5779e201 Merge pull request #45 from abhinavxd/feat/agent-vacation-mode
feat: Toggle button to reassign replies to conversations aka vacation mode
2025-04-05 19:36:00 +05:30
Abhinav Raut
2d868b7df1 fix: keep translation key camelcase 2025-04-05 18:57:09 +05:30
Abhinav Raut
374aabcb10 fix: remove missing query from queries struct 2025-04-05 18:50:19 +05:30
Abhinav Raut
e69b1c3e6d fix: remove missing column reassign_replies from queries 2025-04-05 18:45:31 +05:30
Abhinav Raut
1821647695 feat: allow admins to set availability status
remove unnecessary column `reassign_replies` instead add a new enum `away_and_reassigning` to enum user availability status
2025-04-05 18:12:42 +05:30
Abhinav Raut
b4f2186150 fix[shadcn]: make avatar image src default to empty string 2025-04-05 17:52:11 +05:30
Abhinav Raut
6d588f7a4e feat: show user summary on /admin/users
- Displays metrics like last active at, last login along with the avatar.
- Adds new column `last_login_at` to users table.
- Updates translations for the same
2025-04-04 22:00:26 +05:30
Abhinav Raut
2a382d6036 feat: Toggle button for user to reassign replies to conversations if they are away, user status now actually affects the conversation workflow.
Online: Conversations are auto-assigned.
Auto-away (inactivity in browser): Marks agent as away without stopping assignment (nothing changes for agent).
Manual away: Prevents new conversations from being assigned. (option available in the sidebar)
Reassign replies: Customer replies unassigns the conversation, returning it to the team inbox / unassigned inbox.
2025-04-04 03:29:16 +05:30
Abhinav Raut
c639bfba40 update welcome email subject 2025-04-03 03:15:36 +05:30
Abhinav Raut
82aac02a97 Merge pull request #42 from abhinavxd/feat/cypress-tests
feat:  Cypress test for user login and workflow for E2E testing
2025-04-03 03:09:03 +05:30
Abhinav Raut
c348a5c9b7 rename go test workflow file 2025-04-03 03:05:43 +05:30
Abhinav Raut
008f71d7b4 fix: update cypress dependencies to include libasound2t64 2025-04-03 03:04:51 +05:30
Abhinav Raut
9b41aa0e9a chore: Cypress test for user login and workflow for E2E testing 2025-04-03 03:04:51 +05:30
Abhinav Raut
c60a0788d9 Merge pull request #43 from abhinavxd/feat/translate-app
Translate app
2025-04-03 03:03:46 +05:30
Abhinav Raut
013b5bf37e fix: add sane default values to email inbox form 2025-04-03 03:02:48 +05:30
Abhinav Raut
df0dfb480f fix: enforce stronger password validation rules 2025-04-03 03:02:32 +05:30
Abhinav Raut
2daefccd79 fix: translation keys 2025-04-03 03:00:11 +05:30
Abhinav Raut
f69e8dd4f8 fix: update translation for business hours empty name error 2025-04-03 02:16:51 +05:30
Abhinav Raut
d171958223 fix: translation key param 2025-04-03 02:14:57 +05:30
Abhinav Raut
3b7550fcf3 fix: update incorrect translation key 2025-04-03 02:11:05 +05:30
Abhinav Raut
0de712762c fix: auth initialization with i18n 2025-04-03 01:21:12 +05:30
Abhinav Raut
6b6549cb03 standardize i18n keys for consistency rename keys
fix: avatar url `null` console warnings.
2025-04-03 01:21:12 +05:30
Abhinav Raut
cd4b9a9c23 feat: translate conversations, reply box and message components 2025-04-03 01:21:12 +05:30
Abhinav Raut
e19f817c5f feat: translate account section 2025-04-03 01:21:12 +05:30
Abhinav Raut
5ce8ed72ba feat: translate reports, sla badge, view builder and search 2025-04-03 01:21:12 +05:30
Abhinav Raut
4ec564ee2e feat: translate commandbox, app update and simple table 2025-04-03 01:21:12 +05:30
Abhinav Raut
19f08ec76a feat: translate sso 2025-04-03 01:21:12 +05:30
Abhinav Raut
dd8053b2bb feat: translate templates 2025-04-03 01:21:12 +05:30
Abhinav Raut
72b92d6c66 feat: translate email notifications 2025-04-03 01:21:12 +05:30
Abhinav Raut
497b54fc49 feat: translate automations 2025-04-03 01:21:12 +05:30
Abhinav Raut
9d18d3d08d feat: translations for admin roles 2025-04-03 01:21:12 +05:30
Abhinav Raut
6bea14e7a9 feat: translate /admin/users 2025-04-03 01:21:12 +05:30
Abhinav Raut
25f23735d5 feat: translate admin email inbox forms 2025-04-03 01:21:12 +05:30
Abhinav Raut
3888793450 feat: translate admin conversation status 2025-04-03 01:21:12 +05:30
Abhinav Raut
88e4a55952 feat: translate macros 2025-04-03 01:21:12 +05:30
Abhinav Raut
9aa9a5e1b2 feat: translate /admin/tags 2025-04-03 01:21:12 +05:30
Abhinav Raut
a3098a1dbd feat translate general, business hours, sla.
translate user login , forgot password & set password.
2025-04-03 01:21:12 +05:30
Abhinav Raut
76a24467e7 fix: spanish translation file 2025-04-03 01:21:12 +05:30
Abhinav Raut
4361250c73 feat: backend api response translations 2025-04-03 01:21:12 +05:30
Abhinav Raut
7d9650be2e fix: lock radix-vue and tailwindcss versions to prevent unexpected updates 2025-04-02 13:18:54 +05:30
Abhinav Raut
eb707fd8de Merge pull request #38 from jleroy/fix-unsecure-cookies
Allow to disable cookies secure flag when needed
2025-03-31 01:34:52 +05:30
Jonathan Leroy
36077b1837 Update Config struct description 2025-03-30 19:56:15 +02:00
Abhinav Raut
d5499229b5 Merge pull request #41 from abhinavxd/dependabot/npm_and_yarn/frontend/axios-1.8.2
chore(deps): bump axios from 1.7.9 to 1.8.2 in /frontend
2025-03-27 02:46:48 +05:30
dependabot[bot]
5e90dfee5a chore(deps): bump axios from 1.7.9 to 1.8.2 in /frontend
Bumps [axios](https://github.com/axios/axios) from 1.7.9 to 1.8.2.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.7.9...v1.8.2)

---
updated-dependencies:
- dependency-name: axios
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-26 18:41:39 +00:00
Abhinav Raut
1875a62e00 feat: test cases for SLA calculator, string utilities and attachments 2025-03-26 23:57:56 +05:30
Abhinav Raut
f60c4e8cb6 fix: force tests to run freshly, even if nothing's changed 2025-03-26 23:57:19 +05:30
Abhinav Raut
495ff02067 fix[sla]: incorrect calculated deadline when SLA minutes ends exact at a working day's closing time.
Example test case for it.
```go
		{
			name:      "Monday to Friday 10:00 to 18:00",
			startTime: time.Date(2025, 03, 22, 18, 1, 43, 0, locIST), // Sat
			// 24 hours.
			slaMinutes: 1440,
			businessHours: models.BusinessHours{
				Hours: mustMarshalJSON(map[string]models.WorkingHours{
					"Monday":    {Open: "10:00", Close: "18:00"},
					"Tuesday":   {Open: "10:00", Close: "18:00"},
					"Wednesday": {Open: "10:00", Close: "18:00"},
					"Thursday":  {Open: "10:00", Close: "18:00"},
					"Friday":    {Open: "10:00", Close: "18:00"},
					"Saturday":  {Open: "10:00", Close: "14:00"},
				}),
			},
			timeZone:       "Asia/Kolkata",
			expectedResult: time.Date(2025, 03, 26, 18, 0, 0, 0, locIST),
		},
```
fix: more validations for working hours.
chore: remove unused struct fields in working hours
2025-03-26 23:56:13 +05:30
Abhinav Raut
5afec04c07 feat: add GitHub Actions workflow to run Go tests 2025-03-25 23:43:43 +05:30
Abhinav Raut
56f00e791e feat: add test target to Makefile for running Go tests 2025-03-25 23:43:12 +05:30
Jonathan Leroy
a5a9d1304c Allow to disable cookies secure flag when needed 2025-03-24 12:24:50 +01:00
348 changed files with 16482 additions and 6652 deletions

45
.github/workflows/crowdin.yml vendored Normal file
View File

@@ -0,0 +1,45 @@
name: Crowdin
on:
push:
paths:
# Only trigger a Crowdin update when the source localization file is
# updated.
- 'i18n/en.json'
# Only watches for changes happening on "main" branch.
branches: [ main ]
jobs:
crowdin:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Crowdin push
uses: crowdin/github-action@v2
with:
# Send source (english) strings to Crowdin.
upload_sources: true
# See: https://crowdin.github.io/crowdin-cli/commands
# /crowdin-upload#options
upload_sources_args: '--preserve-hierarchy --delete-obsolete'
# Don't upload or download translations.
upload_translations: false
download_translations: false
# Source language file.
source: 'i18n/en.json'
# Translations files.
translation: 'i18n/%two_letters_code%.json'
env:
# Crowdin.com > Project > Tools > API > Project ID.
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
# When creating a personal token in Crowdin, you'll be asked to select
# the necessary scopes. The basic Crowdin Personal Token scopes are
# the following:
# - Projects (List, Get, Create, Edit) -> Read
# - Translation Status -> Read Only
# - Source files & strings -> Read and Write
# - Translations -> Read and Write
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}

66
.github/workflows/cypress.yml vendored Normal file
View File

@@ -0,0 +1,66 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build-and-test:
runs-on: ubuntu-latest
services:
db:
image: postgres:17-alpine
ports:
- 5432:5432
env:
POSTGRES_USER: libredesk
POSTGRES_PASSWORD: libredesk
POSTGRES_DB: libredesk
options: >-
--health-cmd="pg_isready -U libredesk"
--health-interval=10s
--health-timeout=5s
--health-retries=5
redis:
image: redis
ports:
- 6379:6379
steps:
- uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: "1.24.3"
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: "20"
- name: Install pnpm
run: npm install -g pnpm
- name: Install cypress deps
run: sudo apt-get update && sudo apt-get install -y libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libnss3 libxss1 libasound2t64 libxtst6 xauth xvfb
- name: Build binary and frontend
run: make build
- name: Configure app
run: |
cp config.sample.toml config.toml
- name: Install db schema and run tests
env:
LIBREDESK_SYSTEM_USER_PASSWORD: "StrongPass!123"
run: |
./libredesk --install --idempotent-install --yes --config ./config.toml
./libredesk --upgrade --yes --config ./config.toml
./libredesk --config ./config.toml &
sleep 10
cd frontend
pnpm run test:e2e:ci

22
.github/workflows/go.yml vendored Normal file
View File

@@ -0,0 +1,22 @@
name: Go
on: [push]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.24.3"
- name: Install dependencies
run: go get -v ./...
- name: Run tests
run: make test

View File

@@ -40,7 +40,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.21"
go-version: "1.24.3"
cache: true
- name: Set up Node.js

View File

@@ -71,4 +71,10 @@ stuff: $(STUFFBIN)
.PHONY: demo-build
demo-build:
@echo "→ Building in demo mode..."
@export VITE_DEMO_BUILD="true" && $(MAKE) build
@export VITE_DEMO_BUILD="true" && $(MAKE) build
# Run tests.
.PHONY: test
test:
@echo "→ Running tests..."
go test -count=1 ./...

View File

@@ -7,7 +7,8 @@ Open source, self-hosted customer support desk. Single binary app.
Visit [libredesk.io](https://libredesk.io) for more info. Check out the [**Live demo**](https://demo.libredesk.io/).
![Screenshot_20250220_231723](https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-HvmxvOkalQSLp4qVezdTXaCd3dB4Rm.png)
![image](https://github.com/user-attachments/assets/8e434a02-8b33-41c8-8433-3c98d1d5b834)
> **CAUTION:** This project is currently in **alpha**. Features and APIs may change and are not yet fully tested.
@@ -34,6 +35,8 @@ Visit [libredesk.io](https://libredesk.io) for more info. Check out the [**Live
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.
- **Activity logs**
Track all actions performed by agents and admins—updates and key events across the system—for auditing and accountability.
- **Command Bar**
Opens with a simple shortcut (CTRL+k) and lets you quickly perform actions on conversations.
@@ -82,3 +85,7 @@ __________________
## 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.
## Translators
You can help translate Libredesk into your language on [Crowdin](https://crowdin.com/project/libredesk).

36
cmd/actvity_log.go Normal file
View File

@@ -0,0 +1,36 @@
package main
import (
"strconv"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/zerodha/fastglue"
)
// handleGetActivityLogs returns activity logs from the database.
func handleGetActivityLogs(r *fastglue.Request) error {
var (
app = r.Context.(*App)
order = string(r.RequestCtx.QueryArgs().Peek("order"))
orderBy = string(r.RequestCtx.QueryArgs().Peek("order_by"))
filters = string(r.RequestCtx.QueryArgs().Peek("filters"))
page, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page")))
pageSize, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page_size")))
total = 0
)
logs, err := app.activityLog.GetAll(order, orderBy, filters, page, pageSize)
if err != nil {
return sendErrorEnvelope(r, err)
}
if len(logs) > 0 {
total = logs[0].Total
}
return r.SendEnvelope(envelope.PageResults{
Results: logs,
Total: total,
PerPage: pageSize,
TotalPages: (total + pageSize - 1) / pageSize,
Page: page,
})
}

View File

@@ -43,7 +43,7 @@ func handleUpdateAIProvider(r *fastglue.Request) error {
req providerUpdateReq
)
if err := r.Decode(&req, "json"); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, "Error unmarshalling request", nil))
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil))
}
if err := app.ai.UpdateProvider(req.Provider, req.APIKey); err != nil {
return sendErrorEnvelope(r, err)

View File

@@ -6,6 +6,7 @@ import (
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/stringutil"
realip "github.com/ferluci/fast-realip"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
@@ -22,20 +23,21 @@ func handleOIDCLogin(r *fastglue.Request) error {
)
if err != nil {
app.lo.Error("error parsing provider id", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error parsing provider id.", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.GeneralError)
}
// Set a state and save it in the session, to prevent CSRF attacks.
state, err := stringutil.RandomAlphanumeric(32)
if err != nil {
app.lo.Error("error generating state", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error generating state.", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorGenerating", "name", "state"), nil, envelope.GeneralError)
}
if err = app.auth.SetSessionValues(r, map[string]interface{}{
oidcStateSessKey: state,
}); err != nil {
app.lo.Error("error saving state in session", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error saving state in session.", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorSaving", "name", "{globals.terms.session}"), nil, envelope.GeneralError)
}
authURL, err := app.auth.LoginURL(providerID, state)
@@ -52,30 +54,32 @@ func handleOIDCCallback(r *fastglue.Request) error {
code = string(r.RequestCtx.QueryArgs().Peek("code"))
state = string(r.RequestCtx.QueryArgs().Peek("state"))
providerID, err = strconv.Atoi(string(r.RequestCtx.UserValue("id").(string)))
ip = realip.FromRequest(r.RequestCtx)
)
if err != nil {
app.lo.Error("error parsing provider id", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error parsing provider id.", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.GeneralError)
}
// Compare the state from the session with the state from the query.
sessionState, err := app.auth.GetSessionValue(r, oidcStateSessKey)
if err != nil {
app.lo.Error("error getting state from session", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error getting state from session.", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.session}"), nil, envelope.GeneralError)
}
if state != sessionState {
return r.SendErrorEnvelope(fasthttp.StatusForbidden, "Invalid state.", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.Ts("globals.messages.mismatch", "name", "{globals.terms.state}"), nil, envelope.GeneralError)
}
_, claims, err := app.auth.ExchangeOIDCToken(r.RequestCtx, providerID, code)
if err != nil {
app.lo.Error("error exchanging oidc token", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error exchanging OIDC token.", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError,
app.i18n.T("globals.messages.errorExchangingToken"), nil, envelope.GeneralError)
}
// Lookup the user by email and set the session.
user, err := app.user.GetAgentByEmail(claims.Email)
user, err := app.user.GetAgent(0, claims.Email)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -86,7 +90,18 @@ func handleOIDCCallback(r *fastglue.Request) error {
FirstName: user.FirstName,
LastName: user.LastName,
}, r); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error saving session.", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorSaving", "name", "{globals.terms.session}"), nil, envelope.GeneralError)
}
// Update last login time.
if err := app.user.UpdateLastLoginAt(user.ID); err != nil {
return sendErrorEnvelope(r, err)
}
// Insert activity log.
if err := app.activityLog.Login(user.ID, user.Email.String, ip); err != nil {
app.lo.Error("error creating login activity log", "error", err)
}
return r.Redirect("/", fasthttp.StatusFound, nil, "")

View File

@@ -44,7 +44,7 @@ func handleToggleAutomationRule(r *fastglue.Request) error {
if err := app.automation.ToggleRule(id); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("Rule toggled successfully")
return r.SendEnvelope(true)
}
// handleUpdateAutomationRule updates an automation rule
@@ -55,18 +55,17 @@ func handleUpdateAutomationRule(r *fastglue.Request) error {
id, err = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid rule `id`.", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := r.Decode(&rule, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if err = app.automation.UpdateRule(id, rule);err != nil {
if err = app.automation.UpdateRule(id, rule); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("Rule updated successfully")
return r.SendEnvelope(true)
}
// handleCreateAutomationRule creates a new automation rule
@@ -76,12 +75,12 @@ func handleCreateAutomationRule(r *fastglue.Request) error {
rule = amodels.RuleRecord{}
)
if err := r.Decode(&rule, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if err := app.automation.CreateRule(rule); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("Rule created successfully")
return r.SendEnvelope(true)
}
// handleDeleteAutomationRule deletes an automation rule
@@ -92,15 +91,12 @@ func handleDeleteAutomationRule(r *fastglue.Request) error {
id, err = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid rule `id`.", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
err = app.automation.DeleteRule(id)
if err != nil {
if err = app.automation.DeleteRule(id); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("Rule deleted successfully")
return r.SendEnvelope(true)
}
// handleUpdateAutomationRuleWeights updates the weights of the automation rules
@@ -110,13 +106,13 @@ func handleUpdateAutomationRuleWeights(r *fastglue.Request) error {
weights = make(map[int]int)
)
if err := r.Decode(&weights, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
err := app.automation.UpdateRuleWeights(weights)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("Weights updated successfully")
return r.SendEnvelope(true)
}
// handleUpdateAutomationRuleExecutionMode updates the execution mode of the automation rules for a given type
@@ -126,11 +122,11 @@ func handleUpdateAutomationRuleExecutionMode(r *fastglue.Request) error {
mode = string(r.RequestCtx.PostArgs().Peek("mode"))
)
if mode != amodels.ExecutionModeAll && mode != amodels.ExecutionModeFirstMatch {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid execution mode", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("automation.invalidRuleExecutionMode"), nil, envelope.InputError)
}
// Only new conversation rules can be updated as they are the only ones that have execution mode.
if err := app.automation.UpdateRuleExecutionMode(amodels.RuleTypeNewConversation, mode); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("Execution mode updated successfully")
return r.SendEnvelope(true)
}

View File

@@ -29,14 +29,14 @@ func handleGetBusinessHour(r *fastglue.Request) error {
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid business hour `id`.", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
businessHour, err := app.businessHours.Get(id)
if err != nil {
if err == businessHours.ErrBusinessHoursNotFound {
return r.SendErrorEnvelope(fasthttp.StatusNotFound, err.Error(), nil, envelope.NotFoundError)
}
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error fetching business hour", nil, "")
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.businessHour}"), nil, "")
}
return r.SendEnvelope(businessHour)
}
@@ -48,11 +48,11 @@ func handleCreateBusinessHours(r *fastglue.Request) error {
businessHours = models.BusinessHours{}
)
if err := r.Decode(&businessHours, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if businessHours.Name == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty business hour `Name`", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
}
if err := app.businessHours.Create(businessHours.Name, businessHours.Description, businessHours.IsAlwaysOpen, businessHours.Hours, businessHours.Holidays); err != nil {
@@ -69,14 +69,11 @@ func handleDeleteBusinessHour(r *fastglue.Request) error {
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid business hour `id`.", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
err = app.businessHours.Delete(id)
if err != nil {
if err = app.businessHours.Delete(id); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
@@ -88,20 +85,16 @@ func handleUpdateBusinessHours(r *fastglue.Request) error {
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid business hour `id`.", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := r.Decode(&businessHours, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), err.Error(), envelope.InputError)
}
if businessHours.Name == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty business hour `Name`", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`name`"), nil, envelope.InputError)
}
if err := app.businessHours.Update(id, businessHours.Name, businessHours.Description, businessHours.IsAlwaysOpen, businessHours.Hours, businessHours.Holidays); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}

250
cmd/contacts.go Normal file
View File

@@ -0,0 +1,250 @@
package main
import (
"path/filepath"
"strconv"
"strings"
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/stringutil"
"github.com/abhinavxd/libredesk/internal/user/models"
"github.com/valyala/fasthttp"
"github.com/volatiletech/null/v9"
"github.com/zerodha/fastglue"
)
// handleGetContacts returns a list of contacts from the database.
func handleGetContacts(r *fastglue.Request) error {
var (
app = r.Context.(*App)
order = string(r.RequestCtx.QueryArgs().Peek("order"))
orderBy = string(r.RequestCtx.QueryArgs().Peek("order_by"))
filters = string(r.RequestCtx.QueryArgs().Peek("filters"))
page, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page")))
pageSize, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page_size")))
total = 0
)
contacts, err := app.user.GetContacts(page, pageSize, order, orderBy, filters)
if err != nil {
return sendErrorEnvelope(r, err)
}
if len(contacts) > 0 {
total = contacts[0].Total
}
return r.SendEnvelope(envelope.PageResults{
Results: contacts,
Total: total,
PerPage: pageSize,
TotalPages: (total + pageSize - 1) / pageSize,
Page: page,
})
}
// handleGetTags returns a contact from the database.
func handleGetContact(r *fastglue.Request) error {
var (
app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
c, err := app.user.GetContact(id, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(c)
}
// handleUpdateContact updates a contact in the database.
func handleUpdateContact(r *fastglue.Request) error {
var (
app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
contact, err := app.user.GetContact(id, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
form, err := r.RequestCtx.MultipartForm()
if err != nil {
app.lo.Error("error parsing form data", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.GeneralError)
}
// Parse form data
firstName := ""
if v, ok := form.Value["first_name"]; ok && len(v) > 0 {
firstName = string(v[0])
}
lastName := ""
if v, ok := form.Value["last_name"]; ok && len(v) > 0 {
lastName = string(v[0])
}
email := ""
if v, ok := form.Value["email"]; ok && len(v) > 0 {
email = strings.TrimSpace(string(v[0]))
}
phoneNumber := ""
if v, ok := form.Value["phone_number"]; ok && len(v) > 0 {
phoneNumber = string(v[0])
}
phoneNumberCallingCode := ""
if v, ok := form.Value["phone_number_calling_code"]; ok && len(v) > 0 {
phoneNumberCallingCode = string(v[0])
}
avatarURL := ""
if v, ok := form.Value["avatar_url"]; ok && len(v) > 0 {
avatarURL = string(v[0])
}
// Set nulls to empty strings.
if avatarURL == "null" {
avatarURL = ""
}
if phoneNumberCallingCode == "null" {
phoneNumberCallingCode = ""
}
if phoneNumber == "null" {
phoneNumber = ""
}
// Validate mandatory fields.
if email == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "email"), nil, envelope.InputError)
}
if !stringutil.ValidEmail(email) {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "email"), nil, envelope.InputError)
}
if firstName == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "first_name"), nil, envelope.InputError)
}
// Another contact with same new email?
existingContact, _ := app.user.GetContact(0, email)
if existingContact.ID > 0 && existingContact.ID != id {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("contact.alreadyExistsWithEmail"), nil, envelope.InputError)
}
contactToUpdate := models.User{
FirstName: firstName,
LastName: lastName,
Email: null.StringFrom(email),
AvatarURL: null.NewString(avatarURL, avatarURL != ""),
PhoneNumber: null.NewString(phoneNumber, phoneNumber != ""),
PhoneNumberCallingCode: null.NewString(phoneNumberCallingCode, phoneNumberCallingCode != ""),
}
if err := app.user.UpdateContact(id, contactToUpdate); err != nil {
return sendErrorEnvelope(r, err)
}
// Delete avatar?
if avatarURL == "" && contact.AvatarURL.Valid {
fileName := filepath.Base(contact.AvatarURL.String)
app.media.Delete(fileName)
contact.AvatarURL.Valid = false
contact.AvatarURL.String = ""
}
// Upload avatar?
files, ok := form.File["files"]
if ok && len(files) > 0 {
if err := uploadUserAvatar(r, &contact, files); err != nil {
return sendErrorEnvelope(r, err)
}
}
return r.SendEnvelope(true)
}
// handleGetContactNotes returns all notes for a contact.
func handleGetContactNotes(r *fastglue.Request) error {
var (
app = r.Context.(*App)
contactID, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if contactID <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
notes, err := app.user.GetNotes(contactID)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(notes)
}
// handleCreateContactNote creates a note for a contact.
func handleCreateContactNote(r *fastglue.Request) error {
var (
app = r.Context.(*App)
contactID, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
auser = r.RequestCtx.UserValue("user").(amodels.User)
note = string(r.RequestCtx.PostArgs().Peek("note"))
)
if len(note) == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "note"), nil, envelope.InputError)
}
if err := app.user.CreateNote(contactID, auser.ID, note); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// handleDeleteContactNote deletes a note for a contact.
func handleDeleteContactNote(r *fastglue.Request) error {
var (
app = r.Context.(*App)
contactID, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
noteID, _ = strconv.Atoi(r.RequestCtx.UserValue("note_id").(string))
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
if contactID <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if noteID <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`note_id`"), nil, envelope.InputError)
}
agent, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
// Allow deletion of only own notes and not those created by others, but also allow `Admin` to delete any note.
if !agent.HasAdminRole() {
note, err := app.user.GetNote(noteID)
if err != nil {
return sendErrorEnvelope(r, err)
}
if note.UserID != auser.ID {
return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.Ts("globals.messages.canOnlyDeleteOwn", "name", "{globals.terms.note}"), nil, envelope.InputError)
}
}
if err := app.user.DeleteNote(noteID, contactID); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// handleBlockContact blocks a contact.
func handleBlockContact(r *fastglue.Request) error {
var (
app = r.Context.(*App)
contactID, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
enabled = r.RequestCtx.PostArgs().GetBool("enabled")
)
if contactID <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := app.user.ToggleEnabled(contactID, models.UserTypeContact, enabled); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}

View File

@@ -3,7 +3,6 @@ package main
import (
"encoding/json"
"strconv"
"strings"
"time"
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
@@ -11,12 +10,26 @@ import (
"github.com/abhinavxd/libredesk/internal/automation/models"
cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
"github.com/abhinavxd/libredesk/internal/envelope"
medModels "github.com/abhinavxd/libredesk/internal/media/models"
"github.com/abhinavxd/libredesk/internal/stringutil"
umodels "github.com/abhinavxd/libredesk/internal/user/models"
"github.com/valyala/fasthttp"
"github.com/volatiletech/null/v9"
"github.com/zerodha/fastglue"
)
type createConversationRequest struct {
InboxID int `json:"inbox_id" form:"inbox_id"`
AssignedAgentID int `json:"agent_id" form:"agent_id"`
AssignedTeamID int `json:"team_id" form:"team_id"`
Email string `json:"contact_email" form:"contact_email"`
FirstName string `json:"first_name" form:"first_name"`
LastName string `json:"last_name" form:"last_name"`
Subject string `json:"subject" form:"subject"`
Content string `json:"content" form:"content"`
Attachments []int `json:"attachments" form:"attachments"`
}
// handleGetAllConversations retrieves all conversations.
func handleGetAllConversations(r *fastglue.Request) error {
var (
@@ -61,7 +74,7 @@ func handleGetAssignedConversations(r *fastglue.Request) error {
)
conversations, err := app.conversation.GetAssignedConversationsList(user.ID, order, orderBy, filters, page, pageSize)
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, "")
return sendErrorEnvelope(r, err)
}
if len(conversations) > 0 {
total = conversations[0].Total
@@ -90,7 +103,7 @@ func handleGetUnassignedConversations(r *fastglue.Request) error {
conversations, err := app.conversation.GetUnassignedConversationsList(order, orderBy, filters, page, pageSize)
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, "")
return sendErrorEnvelope(r, err)
}
if len(conversations) > 0 {
total = conversations[0].Total
@@ -118,7 +131,7 @@ func handleGetViewConversations(r *fastglue.Request) error {
total = 0
)
if viewID < 1 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `view_id`", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`view_id`"), nil, envelope.InputError)
}
// Check if user has access to the view.
@@ -127,15 +140,15 @@ func handleGetViewConversations(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
if view.UserID != auser.ID {
return r.SendErrorEnvelope(fasthttp.StatusForbidden, "You don't have access to this view.", nil, envelope.PermissionError)
return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.T("conversation.viewPermissionDenied"), nil, envelope.PermissionError)
}
user, err := app.user.GetAgent(auser.ID)
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
// Prepare lists user has access to based on user permissions, internally this affects the SQL query.
// Prepare lists user has access to based on user permissions, internally this prepares the SQL query.
lists := []string{}
for _, perm := range user.Permissions {
if perm == authzModels.PermConversationsReadAll {
@@ -156,7 +169,7 @@ func handleGetViewConversations(r *fastglue.Request) error {
// No lists found, user doesn't have access to any conversations.
if len(lists) == 0 {
return r.SendErrorEnvelope(fasthttp.StatusForbidden, "Permission denied", nil, envelope.PermissionError)
return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.PermissionError)
}
conversations, err := app.conversation.GetViewConversationsList(user.ID, user.Teams.IDs(), lists, order, orderBy, string(view.Filters), page, pageSize)
@@ -191,7 +204,7 @@ func handleGetTeamUnassignedConversations(r *fastglue.Request) error {
)
teamID, _ := strconv.Atoi(teamIDStr)
if teamID < 1 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `team_id`", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`team_id`"), nil, envelope.InputError)
}
// Check if user belongs to the team.
@@ -201,7 +214,7 @@ func handleGetTeamUnassignedConversations(r *fastglue.Request) error {
}
if !exists {
return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, "You're not a member of this team, Please refresh the page and try again.", nil))
return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, app.i18n.T("conversation.notMemberOfTeam"), nil))
}
conversations, err := app.conversation.GetTeamUnassignedConversationsList(teamID, order, orderBy, filters, page, pageSize)
@@ -229,7 +242,7 @@ func handleGetConversation(r *fastglue.Request) error {
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.GetAgent(auser.ID)
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -251,7 +264,7 @@ func handleUpdateConversationAssigneeLastSeen(r *fastglue.Request) error {
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.GetAgent(auser.ID)
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -262,7 +275,7 @@ func handleUpdateConversationAssigneeLastSeen(r *fastglue.Request) error {
if err = app.conversation.UpdateConversationAssigneeLastSeen(uuid); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("Last seen updated successfully")
return r.SendEnvelope(true)
}
// handleGetConversationParticipants retrieves participants of a conversation.
@@ -272,7 +285,7 @@ func handleGetConversationParticipants(r *fastglue.Request) error {
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.GetAgent(auser.ID)
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -296,10 +309,10 @@ func handleUpdateUserAssignee(r *fastglue.Request) error {
assigneeID = r.RequestCtx.PostArgs().GetUintOrZero("assignee_id")
)
if assigneeID == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `assignee_id`", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`assignee_id`"), nil, envelope.InputError)
}
user, err := app.user.GetAgent(auser.ID)
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -316,7 +329,7 @@ func handleUpdateUserAssignee(r *fastglue.Request) error {
// Evaluate automation rules.
app.automation.EvaluateConversationUpdateRules(uuid, models.EventConversationUserAssigned)
return r.SendEnvelope("User assigned successfully")
return r.SendEnvelope(true)
}
// handleUpdateTeamAssignee updates the team assigned to a conversation.
@@ -328,10 +341,10 @@ func handleUpdateTeamAssignee(r *fastglue.Request) error {
)
assigneeID, err := r.RequestCtx.PostArgs().GetUint("assignee_id")
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid assignee `id`.", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`assignee_id`"), nil, envelope.InputError)
}
user, err := app.user.GetAgent(auser.ID)
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -352,7 +365,7 @@ func handleUpdateTeamAssignee(r *fastglue.Request) error {
// Evaluate automation rules on team assignment.
app.automation.EvaluateConversationUpdateRules(uuid, models.EventConversationTeamAssigned)
return r.SendEnvelope("Team assigned successfully")
return r.SendEnvelope(true)
}
// handleUpdateConversationPriority updates the priority of a conversation.
@@ -364,10 +377,10 @@ func handleUpdateConversationPriority(r *fastglue.Request) error {
priority = string(r.RequestCtx.PostArgs().Peek("priority"))
)
if priority == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `priority`", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`priority`"), nil, envelope.InputError)
}
user, err := app.user.GetAgent(auser.ID)
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -382,7 +395,7 @@ func handleUpdateConversationPriority(r *fastglue.Request) error {
// Evaluate automation rules.
app.automation.EvaluateConversationUpdateRules(uuid, models.EventConversationPriorityChange)
return r.SendEnvelope("Priority updated successfully")
return r.SendEnvelope(true)
}
// handleUpdateConversationStatus updates the status of a conversation.
@@ -397,20 +410,20 @@ func handleUpdateConversationStatus(r *fastglue.Request) error {
// Validate inputs
if status == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `status`", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`status`"), nil, envelope.InputError)
}
if snoozedUntil == "" && status == cmodels.StatusSnoozed {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `snoozed_until`", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`snoozed_until`"), nil, envelope.InputError)
}
if status == cmodels.StatusSnoozed {
_, err := time.ParseDuration(snoozedUntil)
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `snoozed_until`", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`snoozed_until`"), nil, envelope.InputError)
}
}
// Enforce conversation access.
user, err := app.user.GetAgent(auser.ID)
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -421,7 +434,7 @@ func handleUpdateConversationStatus(r *fastglue.Request) error {
// Make sure a user is assigned before resolving conversation.
if status == cmodels.StatusResolved && conversation.AssignedUserID.Int == 0 {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, "Cannot resolve the conversation without an assigned user, Please assign a user before attempting to resolve.", nil))
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("conversation.resolveWithoutAssignee"), nil))
}
// Update conversation status.
@@ -445,7 +458,7 @@ func handleUpdateConversationStatus(r *fastglue.Request) error {
}
}
}
return r.SendEnvelope("Status updated successfully")
return r.SendEnvelope(true)
}
// handleUpdateConversationtags updates conversation tags.
@@ -460,10 +473,10 @@ func handleUpdateConversationtags(r *fastglue.Request) error {
if err := json.Unmarshal(tagJSON, &tagNames); err != nil {
app.lo.Error("error unmarshalling tags JSON", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error unmarshalling tags JSON", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
user, err := app.user.GetAgent(auser.ID)
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -472,10 +485,68 @@ func handleUpdateConversationtags(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
if err := app.conversation.UpsertConversationTags(uuid, tagNames, user); err != nil {
if err := app.conversation.SetConversationTags(uuid, models.ActionSetTags, tagNames, user); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("Tags added successfully")
return r.SendEnvelope(true)
}
// handleUpdateConversationCustomAttributes updates custom attributes of a conversation.
func handleUpdateConversationCustomAttributes(r *fastglue.Request) error {
var (
app = r.Context.(*App)
attributes = map[string]any{}
auser = r.RequestCtx.UserValue("user").(amodels.User)
uuid = r.RequestCtx.UserValue("uuid").(string)
)
if err := r.Decode(&attributes, ""); err != nil {
app.lo.Error("error unmarshalling custom attributes JSON", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
// Enforce conversation access.
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
_, err = enforceConversationAccess(app, uuid, user)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Update custom attributes.
if err := app.conversation.UpdateConversationCustomAttributes(uuid, attributes); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// handleUpdateContactCustomAttributes updates custom attributes of a contact.
func handleUpdateContactCustomAttributes(r *fastglue.Request) error {
var (
app = r.Context.(*App)
attributes = map[string]any{}
auser = r.RequestCtx.UserValue("user").(amodels.User)
uuid = r.RequestCtx.UserValue("uuid").(string)
)
if err := r.Decode(&attributes, ""); err != nil {
app.lo.Error("error unmarshalling custom attributes JSON", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
// Enforce conversation access.
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
conversation, err := enforceConversationAccess(app, uuid, user)
if err != nil {
return sendErrorEnvelope(r, err)
}
if err := app.user.UpdateCustomAttributes(conversation.ContactID, attributes); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// handleDashboardCounts retrieves general dashboard counts for all users.
@@ -510,7 +581,7 @@ func enforceConversationAccess(app *App, uuid string, user umodels.User) (*cmode
}
allowed, err := app.authz.EnforceConversationAccess(user, conversation)
if err != nil {
return nil, envelope.NewError(envelope.GeneralError, "Error checking permissions", nil)
return nil, err
}
if !allowed {
return nil, envelope.NewError(envelope.PermissionError, "Permission denied", nil)
@@ -525,7 +596,7 @@ func handleRemoveUserAssignee(r *fastglue.Request) error {
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.GetAgent(auser.ID)
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -546,7 +617,7 @@ func handleRemoveTeamAssignee(r *fastglue.Request) error {
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.GetAgent(auser.ID)
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -573,95 +644,105 @@ func filterCurrentConv(convs []cmodels.Conversation, uuid string) []cmodels.Conv
// 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"))
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
req = createConversationRequest{}
)
// 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)
if err := r.Decode(&req, "json"); err != nil {
app.lo.Error("error decoding create conversation request", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
user, err := app.user.GetAgent(auser.ID)
to := []string{req.Email}
// Validate required fields
if req.InboxID <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`inbox_id`"), nil, envelope.InputError)
}
if req.Content == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`content`"), nil, envelope.InputError)
}
if req.Email == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`contact_email`"), nil, envelope.InputError)
}
if req.FirstName == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`first_name`"), nil, envelope.InputError)
}
if !stringutil.ValidEmail(req.Email) {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`contact_email`"), 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)
inbox, err := app.inbox.GetDBRecord(req.InboxID)
if err != nil {
return sendErrorEnvelope(r, err)
}
if !inbox.Enabled {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "The chosen inbox is disabled", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.disabled", "name", "inbox"), nil, envelope.InputError)
}
// Find or create contact.
contact := umodels.User{
Email: null.StringFrom(email),
SourceChannelID: null.StringFrom(email),
FirstName: firstName,
LastName: lastName,
InboxID: inboxID,
Email: null.StringFrom(req.Email),
SourceChannelID: null.StringFrom(req.Email),
FirstName: req.FirstName,
LastName: req.LastName,
InboxID: req.InboxID,
}
if err := app.user.CreateContact(&contact); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, "Error creating contact", nil))
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.contact}"), nil))
}
// Create conversation
conversationID, conversationUUID, err := app.conversation.CreateConversation(
contact.ID,
contact.ContactChannelID,
inboxID,
"", /** last_message **/
time.Now(),
subject,
req.InboxID,
"", /** last_message **/
time.Now(), /** last_message_at **/
req.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))
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.conversation}"), nil))
}
// Prepare attachments.
var media = make([]medModels.Media, 0, len(req.Attachments))
for _, id := range req.Attachments {
m, err := app.media.Get(id, "")
if err != nil {
app.lo.Error("error fetching media", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.media}"), nil, envelope.GeneralError)
}
media = append(media, m)
}
// 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.SendReply(media, req.InboxID, auser.ID /**sender_id**/, conversationUUID, req.Content, to, nil /**cc**/, nil /**bcc**/, map[string]any{} /**meta**/); err != nil {
// Delete the conversation if reply fails.
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))
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorSending", "name", "{globals.terms.message}"), nil))
}
// Assign the conversation to the agent or team.
if assignedAgentID > 0 {
app.conversation.UpdateConversationUserAssignee(conversationUUID, assignedAgentID, user)
if req.AssignedAgentID > 0 {
app.conversation.UpdateConversationUserAssignee(conversationUUID, req.AssignedAgentID, user)
}
if assignedTeamID > 0 {
app.conversation.UpdateConversationTeamAssignee(conversationUUID, assignedTeamID, user)
if req.AssignedTeamID > 0 {
app.conversation.UpdateConversationTeamAssignee(conversationUUID, req.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)
}
conversation, _ := app.conversation.GetConversation(conversationID, "")
return r.SendEnvelope(conversation)
}

137
cmd/custom_attributes.go Normal file
View File

@@ -0,0 +1,137 @@
package main
import (
"slices"
"strconv"
cmodels "github.com/abhinavxd/libredesk/internal/custom_attribute/models"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
var (
// disallowedKeys contains keys that are not allowed for custom attributes as they're the default fields.
disallowedKeys = []string{
"contact_email",
"content",
"subject",
"status",
"priority",
"assigned_team",
"assigned_user",
"hours_since_created",
"hours_since_first_reply",
"hours_since_last_reply",
"hours_since_resolved",
"inbox",
}
)
// handleGetCustomAttribute retrieves a custom attribute by its ID.
func handleGetCustomAttribute(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
attribute, err := app.customAttribute.Get(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(attribute)
}
// handleGetCustomAttributes retrieves all custom attributes from the database.
func handleGetCustomAttributes(r *fastglue.Request) error {
var (
app = r.Context.(*App)
appliesTo = string(r.RequestCtx.QueryArgs().Peek("applies_to"))
)
attributes, err := app.customAttribute.GetAll(appliesTo)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(attributes)
}
// handleCreateCustomAttribute creates a new custom attribute in the database.
func handleCreateCustomAttribute(r *fastglue.Request) error {
var (
app = r.Context.(*App)
attribute = cmodels.CustomAttribute{}
)
if err := r.Decode(&attribute, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), err.Error(), envelope.InputError)
}
if err := validateCustomAttribute(app, attribute); err != nil {
return sendErrorEnvelope(r, err)
}
if err := app.customAttribute.Create(attribute); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// handleUpdateCustomAttribute updates an existing custom attribute in the database.
func handleUpdateCustomAttribute(r *fastglue.Request) error {
var (
app = r.Context.(*App)
attribute = cmodels.CustomAttribute{}
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := r.Decode(&attribute, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), err.Error(), envelope.InputError)
}
if err := validateCustomAttribute(app, attribute); err != nil {
return sendErrorEnvelope(r, err)
}
if err = app.customAttribute.Update(id, attribute); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// handleDeleteCustomAttribute deletes a custom attribute from the database.
func handleDeleteCustomAttribute(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err = app.customAttribute.Delete(id); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// validateCustomAttribute validates a custom attribute.
func validateCustomAttribute(app *App, attribute cmodels.CustomAttribute) error {
if attribute.Name == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil)
}
if attribute.AppliesTo == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`applies_to`"), nil)
}
if attribute.DataType == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`type`"), nil)
}
if attribute.Description == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`description`"), nil)
}
if attribute.Key == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`key`"), nil)
}
if slices.Contains(disallowedKeys, attribute.Key) {
return envelope.NewError(envelope.InputError, app.i18n.T("admin.customAttributes.keyNotAllowed"), nil)
}
return nil
}

View File

@@ -16,10 +16,13 @@ import (
func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
// Authentication.
g.POST("/api/v1/login", handleLogin)
g.GET("/logout", handleLogout)
g.GET("/logout", auth(handleLogout))
g.GET("/api/v1/oidc/{id}/login", handleOIDCLogin)
g.GET("/api/v1/oidc/{id}/finish", handleOIDCCallback)
// i18n.
g.GET("/api/v1/lang/{lang}", handleGetI18nLang)
// Media.
g.GET("/uploads/{uuid}", auth(handleServeMedia))
g.POST("/api/v1/media", auth(handleMediaUpload))
@@ -60,11 +63,13 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
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"))
g.PUT("/api/v1/conversations/{uuid}/custom-attributes", auth(handleUpdateConversationCustomAttributes))
g.PUT("/api/v1/conversations/{uuid}/contacts/custom-attributes", auth(handleUpdateContactCustomAttributes))
// 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"))
g.GET("/api/v1/contacts/search", perm(handleSearchContacts, "contacts:read"))
// Views.
g.GET("/api/v1/views/me", perm(handleGetUserViews, "view:manage"))
@@ -79,7 +84,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.DELETE("/api/v1/statuses/{id}", perm(handleDeleteStatus, "status:manage"))
g.GET("/api/v1/priorities", auth(handleGetPriorities))
// Tag.
// Tags.
g.GET("/api/v1/tags", auth(handleGetTags))
g.POST("/api/v1/tags", perm(handleCreateTag, "tags:manage"))
g.PUT("/api/v1/tags/{id}", perm(handleUpdateTag, "tags:manage"))
@@ -93,22 +98,34 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.DELETE("/api/v1/macros/{id}", perm(handleDeleteMacro, "macros:manage"))
g.POST("/api/v1/conversations/{uuid}/macros/{id}/apply", auth(handleApplyMacro))
// User.
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"))
g.GET("/api/v1/users/{id}", perm(handleGetUser, "users:manage"))
g.POST("/api/v1/users", perm(handleCreateUser, "users:manage"))
g.PUT("/api/v1/users/{id}", perm(handleUpdateUser, "users:manage"))
g.DELETE("/api/v1/users/{id}", perm(handleDeleteUser, "users:manage"))
g.POST("/api/v1/users/reset-password", tryAuth(handleResetPassword))
g.POST("/api/v1/users/set-password", tryAuth(handleSetPassword))
// Agents.
g.GET("/api/v1/agents/me", auth(handleGetCurrentAgent))
g.PUT("/api/v1/agents/me", auth(handleUpdateCurrentAgent))
g.GET("/api/v1/agents/me/teams", auth(handleGetCurrentAgentTeams))
g.PUT("/api/v1/agents/me/availability", auth(handleUpdateAgentAvailability))
g.DELETE("/api/v1/agents/me/avatar", auth(handleDeleteCurrentAgentAvatar))
// Team.
g.GET("/api/v1/agents/compact", auth(handleGetAgentsCompact))
g.GET("/api/v1/agents", perm(handleGetAgents, "users:manage"))
g.GET("/api/v1/agents/{id}", perm(handleGetAgent, "users:manage"))
g.POST("/api/v1/agents", perm(handleCreateAgent, "users:manage"))
g.PUT("/api/v1/agents/{id}", perm(handleUpdateAgent, "users:manage"))
g.DELETE("/api/v1/agents/{id}", perm(handleDeleteAgent, "users:manage"))
g.POST("/api/v1/agents/reset-password", tryAuth(handleResetPassword))
g.POST("/api/v1/agents/set-password", tryAuth(handleSetPassword))
// Contacts.
g.GET("/api/v1/contacts", perm(handleGetContacts, "contacts:read_all"))
g.GET("/api/v1/contacts/{id}", perm(handleGetContact, "contacts:read"))
g.PUT("/api/v1/contacts/{id}", perm(handleUpdateContact, "contacts:write"))
g.PUT("/api/v1/contacts/{id}/block", perm(handleBlockContact, "contacts:block"))
// Contact notes.
g.GET("/api/v1/contacts/{id}/notes", perm(handleGetContactNotes, "contact_notes:read"))
g.POST("/api/v1/contacts/{id}/notes", perm(handleCreateContactNote, "contact_notes:write"))
g.DELETE("/api/v1/contacts/{id}/notes/{note_id}", perm(handleDeleteContactNote, "contact_notes:delete"))
// Teams.
g.GET("/api/v1/teams/compact", auth(handleGetTeamsCompact))
g.GET("/api/v1/teams", perm(handleGetTeams, "teams:manage"))
g.GET("/api/v1/teams/{id}", perm(handleGetTeam, "teams:manage"))
@@ -116,20 +133,17 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.PUT("/api/v1/teams/{id}", perm(handleUpdateTeam, "teams:manage"))
g.DELETE("/api/v1/teams/{id}", perm(handleDeleteTeam, "teams:manage"))
// i18n.
g.GET("/api/v1/lang/{lang}", handleGetI18nLang)
// Automations.
g.GET("/api/v1/automations/rules", perm(handleGetAutomationRules, "automations:manage"))
g.GET("/api/v1/automations/rules/{id}", perm(handleGetAutomationRule, "automations:manage"))
g.POST("/api/v1/automations/rules", perm(handleCreateAutomationRule, "automations:manage"))
g.PUT("/api/v1/automations/rules/{id}/toggle", perm(handleToggleAutomationRule, "automations:manage"))
g.PUT("/api/v1/automations/rules/{id}", perm(handleUpdateAutomationRule, "automations:manage"))
g.PUT("/api/v1/automations/rules/weights", perm(handleUpdateAutomationRuleWeights, "automations:manage"))
g.PUT("/api/v1/automations/rules/execution-mode", perm(handleUpdateAutomationRuleExecutionMode, "automations:manage"))
g.DELETE("/api/v1/automations/rules/{id}", perm(handleDeleteAutomationRule, "automations:manage"))
// Automation.
g.GET("/api/v1/automation/rules", perm(handleGetAutomationRules, "automations:manage"))
g.GET("/api/v1/automation/rules/{id}", perm(handleGetAutomationRule, "automations:manage"))
g.POST("/api/v1/automation/rules", perm(handleCreateAutomationRule, "automations:manage"))
g.PUT("/api/v1/automation/rules/{id}/toggle", perm(handleToggleAutomationRule, "automations:manage"))
g.PUT("/api/v1/automation/rules/{id}", perm(handleUpdateAutomationRule, "automations:manage"))
g.PUT("/api/v1/automation/rules/weights", perm(handleUpdateAutomationRuleWeights, "automations:manage"))
g.PUT("/api/v1/automation/rules/execution-mode", perm(handleUpdateAutomationRuleExecutionMode, "automations:manage"))
g.DELETE("/api/v1/automation/rules/{id}", perm(handleDeleteAutomationRule, "automations:manage"))
// Inbox.
// Inboxes.
g.GET("/api/v1/inboxes", auth(handleGetInboxes))
g.GET("/api/v1/inboxes/{id}", perm(handleGetInbox, "inboxes:manage"))
g.POST("/api/v1/inboxes", perm(handleCreateInbox, "inboxes:manage"))
@@ -137,18 +151,18 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.PUT("/api/v1/inboxes/{id}", perm(handleUpdateInbox, "inboxes:manage"))
g.DELETE("/api/v1/inboxes/{id}", perm(handleDeleteInbox, "inboxes:manage"))
// Role.
// Roles.
g.GET("/api/v1/roles", perm(handleGetRoles, "roles:manage"))
g.GET("/api/v1/roles/{id}", perm(handleGetRole, "roles:manage"))
g.POST("/api/v1/roles", perm(handleCreateRole, "roles:manage"))
g.PUT("/api/v1/roles/{id}", perm(handleUpdateRole, "roles:manage"))
g.DELETE("/api/v1/roles/{id}", perm(handleDeleteRole, "roles:manage"))
// Dashboard.
// Reports.
g.GET("/api/v1/reports/overview/counts", perm(handleDashboardCounts, "reports:manage"))
g.GET("/api/v1/reports/overview/charts", perm(handleDashboardCharts, "reports:manage"))
// Template.
// Templates.
g.GET("/api/v1/templates", perm(handleGetTemplates, "templates:manage"))
g.GET("/api/v1/templates/{id}", perm(handleGetTemplate, "templates:manage"))
g.POST("/api/v1/templates", perm(handleCreateTemplate, "templates:manage"))
@@ -156,24 +170,34 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.DELETE("/api/v1/templates/{id}", perm(handleDeleteTemplate, "templates:manage"))
// Business hours.
g.GET("/api/v1/business-hours", perm(handleGetBusinessHours, "business_hours:manage"))
g.GET("/api/v1/business-hours", auth(handleGetBusinessHours))
g.GET("/api/v1/business-hours/{id}", perm(handleGetBusinessHour, "business_hours:manage"))
g.POST("/api/v1/business-hours", perm(handleCreateBusinessHours, "business_hours:manage"))
g.PUT("/api/v1/business-hours/{id}", perm(handleUpdateBusinessHours, "business_hours:manage"))
g.DELETE("/api/v1/business-hours/{id}", perm(handleDeleteBusinessHour, "business_hours:manage"))
// SLA.
// SLAs.
g.GET("/api/v1/sla", perm(handleGetSLAs, "sla:manage"))
g.GET("/api/v1/sla/{id}", perm(handleGetSLA, "sla:manage"))
g.POST("/api/v1/sla", perm(handleCreateSLA, "sla:manage"))
g.PUT("/api/v1/sla/{id}", perm(handleUpdateSLA, "sla:manage"))
g.DELETE("/api/v1/sla/{id}", perm(handleDeleteSLA, "sla:manage"))
// AI completion.
// AI completions.
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"))
// Custom attributes.
g.GET("/api/v1/custom-attributes", auth(handleGetCustomAttributes))
g.POST("/api/v1/custom-attributes", perm(handleCreateCustomAttribute, "custom_attributes:manage"))
g.GET("/api/v1/custom-attributes/{id}", perm(handleGetCustomAttribute, "custom_attributes:manage"))
g.PUT("/api/v1/custom-attributes/{id}", perm(handleUpdateCustomAttribute, "custom_attributes:manage"))
g.DELETE("/api/v1/custom-attributes/{id}", perm(handleDeleteCustomAttribute, "custom_attributes:manage"))
// Actvity logs.
g.GET("/api/v1/activity-logs", perm(handleGetActivityLogs, "activity_logs:manage"))
// WebSocket.
g.GET("/ws", auth(func(r *fastglue.Request) error {
return handleWS(r, hub)
@@ -185,6 +209,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.GET("/teams/{all:*}", authPage(serveIndexPage))
g.GET("/views/{all:*}", authPage(serveIndexPage))
g.GET("/admin/{all:*}", authPage(serveIndexPage))
g.GET("/contacts/{all:*}", authPage(serveIndexPage))
g.GET("/reports/{all:*}", authPage(serveIndexPage))
g.GET("/account/{all:*}", authPage(serveIndexPage))
g.GET("/reset-password", notAuthPage(serveIndexPage))
@@ -214,7 +239,7 @@ func serveIndexPage(r *fastglue.Request) error {
// Serve the index.html file from the embedded filesystem.
file, err := app.fs.Get(path.Join(frontendDir, "index.html"))
if err != nil {
return r.SendErrorEnvelope(http.StatusNotFound, "Page not found", nil, envelope.NotFoundError)
return r.SendErrorEnvelope(http.StatusNotFound, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.file}"), nil, envelope.NotFoundError)
}
r.RequestCtx.Response.Header.Set("Content-Type", "text/html")
r.RequestCtx.SetBody(file.ReadBytes())
@@ -222,7 +247,7 @@ func serveIndexPage(r *fastglue.Request) error {
// Set CSRF cookie if not already set.
if err := app.auth.SetCSRFCookie(r); err != nil {
app.lo.Error("error setting csrf cookie", "error", err)
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("user.errorAcquiringSession"), nil))
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorSaving", "name", "{globals.terms.session}"), nil))
}
return nil
}
@@ -236,7 +261,7 @@ func serveStaticFiles(r *fastglue.Request) error {
file, err := app.fs.Get(filePath)
if err != nil {
return r.SendErrorEnvelope(http.StatusNotFound, "File not found", nil, envelope.NotFoundError)
return r.SendErrorEnvelope(http.StatusNotFound, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.file}"), nil, envelope.NotFoundError)
}
// Set the appropriate Content-Type based on the file extension.
@@ -261,7 +286,7 @@ func serveFrontendStaticFiles(r *fastglue.Request) error {
finalPath := filepath.Join(frontendDir, filePath)
file, err := app.fs.Get(finalPath)
if err != nil {
return r.SendErrorEnvelope(http.StatusNotFound, "File not found", nil, envelope.NotFoundError)
return r.SendErrorEnvelope(http.StatusNotFound, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.file}"), nil, envelope.NotFoundError)
}
// Set the appropriate Content-Type based on the file extension.

View File

@@ -27,6 +27,7 @@ func handleGetI18nLang(r *fastglue.Request) error {
return r.SendBytes(http.StatusOK, "application/json", i.JSON())
}
// loadI18nLang loads the i18n language pack for the given language code.
func loadI18nLang(lang string, fs stuffbin.FileSystem) (*i18n.I18n, error) {
// Helper function to read and initialize i18n language.
readLang := func(lang string) ([]byte, error) {

View File

@@ -10,6 +10,7 @@ import (
"github.com/zerodha/fastglue"
)
// handleGetInboxes returns all inboxes
func handleGetInboxes(r *fastglue.Request) error {
var app = r.Context.(*App)
inboxes, err := app.inbox.GetAll()
@@ -19,6 +20,7 @@ func handleGetInboxes(r *fastglue.Request) error {
return r.SendEnvelope(inboxes)
}
// handleGetInbox returns an inbox by ID
func handleGetInbox(r *fastglue.Request) error {
var (
app = r.Context.(*App)
@@ -26,34 +28,35 @@ func handleGetInbox(r *fastglue.Request) error {
)
inbox, err := app.inbox.GetDBRecord(id)
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error fetching inbox", nil, envelope.GeneralError)
return sendErrorEnvelope(r, err)
}
if err := inbox.ClearPasswords(); err != nil {
app.lo.Error("error clearing out passwords", "error", err)
return envelope.NewError(envelope.GeneralError, "Error fetching inbox", nil)
app.lo.Error("error clearing inbox passwords from response", "error", err)
return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.inbox}"), nil)
}
return r.SendEnvelope(inbox)
}
// handleCreateInbox creates a new inbox
func handleCreateInbox(r *fastglue.Request) error {
var (
app = r.Context.(*App)
inbox = imodels.Inbox{}
)
if err := r.Decode(&inbox, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), err.Error(), envelope.InputError)
}
if err := app.inbox.Create(inbox); err != nil {
return sendErrorEnvelope(r, err)
}
if err := validateInbox(inbox); err != nil {
if err := validateInbox(app, inbox); err != nil {
return sendErrorEnvelope(r, err)
}
if err := reloadInboxes(app); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error reloading inboxes", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.couldNotReload", "name", "{globals.terms.inbox}"), nil, envelope.GeneralError)
}
return r.SendEnvelope(true)
@@ -68,24 +71,24 @@ func handleUpdateInbox(r *fastglue.Request) error {
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid inbox `id`.", nil, envelope.InputError)
app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := r.Decode(&inbox, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), err.Error(), envelope.InputError)
}
if err := validateInbox(inbox); err != nil {
if err := validateInbox(app, inbox); err != nil {
return sendErrorEnvelope(r, err)
}
err = app.inbox.Update(id, inbox)
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Could not update inbox.", nil, envelope.GeneralError)
return sendErrorEnvelope(r, err)
}
if err := reloadInboxes(app); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error reloading inboxes, Please restart the app.", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.couldNotReload", "name", "{globals.terms.inbox}"), nil, envelope.GeneralError)
}
return r.SendEnvelope(inbox)
@@ -99,7 +102,7 @@ func handleToggleInbox(r *fastglue.Request) error {
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid inbox `id`.", nil, envelope.InputError)
app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err = app.inbox.Toggle(id); err != nil {
@@ -107,7 +110,7 @@ func handleToggleInbox(r *fastglue.Request) error {
}
if err := reloadInboxes(app); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error reloading inboxes", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.couldNotReload", "name", "{globals.terms.inbox}"), nil, envelope.GeneralError)
}
return r.SendEnvelope(true)
@@ -121,33 +124,28 @@ func handleDeleteInbox(r *fastglue.Request) error {
)
err := app.inbox.SoftDelete(id)
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Could not update inbox.", nil, envelope.GeneralError)
return sendErrorEnvelope(r, err)
}
if err := reloadInboxes(app); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error reloading inboxes", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.couldNotReload", "name", "{globals.terms.inbox}"), nil, envelope.GeneralError)
}
return r.SendEnvelope(true)
}
// validateInbox validates the inbox
func validateInbox(inbox imodels.Inbox) error {
// Make sure it's a valid from email address.
func validateInbox(app *App, inbox imodels.Inbox) error {
// Validate from address.
if _, err := mail.ParseAddress(inbox.From); err != nil {
return envelope.NewError(envelope.InputError, "Invalid from email address format, make sure it's a valid email address in the format `Name <mail@example.com>`", nil)
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalidFromAddress"), nil)
}
if len(inbox.Config) == 0 {
return envelope.NewError(envelope.InputError, "Empty config provided for inbox", nil)
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "config"), nil)
}
if inbox.Name == "" {
return envelope.NewError(envelope.InputError, "Empty name provided for inbox", nil)
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "name"), nil)
}
if inbox.Channel == "" {
return envelope.NewError(envelope.InputError, "Empty channel provided for inbox", nil)
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "channel"), nil)
}
return nil
}

View File

@@ -12,6 +12,7 @@ import (
"html/template"
activitylog "github.com/abhinavxd/libredesk/internal/activity_log"
"github.com/abhinavxd/libredesk/internal/ai"
auth_ "github.com/abhinavxd/libredesk/internal/auth"
"github.com/abhinavxd/libredesk/internal/authz"
@@ -23,6 +24,7 @@ import (
"github.com/abhinavxd/libredesk/internal/conversation/priority"
"github.com/abhinavxd/libredesk/internal/conversation/status"
"github.com/abhinavxd/libredesk/internal/csat"
customAttribute "github.com/abhinavxd/libredesk/internal/custom_attribute"
"github.com/abhinavxd/libredesk/internal/inbox"
"github.com/abhinavxd/libredesk/internal/inbox/channel/email"
imodels "github.com/abhinavxd/libredesk/internal/inbox/models"
@@ -231,11 +233,12 @@ func initConversations(
}
// initTag inits tag manager.
func initTag(db *sqlx.DB) *tag.Manager {
func initTag(db *sqlx.DB, i18n *i18n.I18n) *tag.Manager {
var lo = initLogger("tag_manager")
mgr, err := tag.New(tag.Opts{
DB: db,
Lo: lo,
DB: db,
Lo: lo,
I18n: i18n,
})
if err != nil {
log.Fatalf("error initializing tags: %v", err)
@@ -257,11 +260,12 @@ func initView(db *sqlx.DB) *view.Manager {
}
// initMacro inits macro manager.
func initMacro(db *sqlx.DB) *macro.Manager {
func initMacro(db *sqlx.DB, i18n *i18n.I18n) *macro.Manager {
var lo = initLogger("macro")
m, err := macro.New(macro.Opts{
DB: db,
Lo: lo,
DB: db,
Lo: lo,
I18n: i18n,
})
if err != nil {
log.Fatalf("error initializing macro manager: %v", err)
@@ -270,11 +274,12 @@ func initMacro(db *sqlx.DB) *macro.Manager {
}
// initBusinessHours inits business hours manager.
func initBusinessHours(db *sqlx.DB) *businesshours.Manager {
func initBusinessHours(db *sqlx.DB, i18n *i18n.I18n) *businesshours.Manager {
var lo = initLogger("business-hours")
m, err := businesshours.New(businesshours.Opts{
DB: db,
Lo: lo,
DB: db,
Lo: lo,
I18n: i18n,
})
if err != nil {
log.Fatalf("error initializing business hours manager: %v", err)
@@ -283,11 +288,12 @@ func initBusinessHours(db *sqlx.DB) *businesshours.Manager {
}
// initSLA inits SLA manager.
func initSLA(db *sqlx.DB, teamManager *team.Manager, settings *setting.Manager, businessHours *businesshours.Manager, notifier *notifier.Service, template *tmpl.Manager, userManager *user.Manager) *sla.Manager {
func initSLA(db *sqlx.DB, teamManager *team.Manager, settings *setting.Manager, businessHours *businesshours.Manager, notifier *notifier.Service, template *tmpl.Manager, userManager *user.Manager, i18n *i18n.I18n) *sla.Manager {
var lo = initLogger("sla")
m, err := sla.New(sla.Opts{
DB: db,
Lo: lo,
DB: db,
Lo: lo,
I18n: i18n,
}, teamManager, settings, businessHours, notifier, template, userManager)
if err != nil {
log.Fatalf("error initializing SLA manager: %v", err)
@@ -296,11 +302,12 @@ func initSLA(db *sqlx.DB, teamManager *team.Manager, settings *setting.Manager,
}
// initCSAT inits CSAT manager.
func initCSAT(db *sqlx.DB) *csat.Manager {
func initCSAT(db *sqlx.DB, i18n *i18n.I18n) *csat.Manager {
var lo = initLogger("csat")
m, err := csat.New(csat.Opts{
DB: db,
Lo: lo,
DB: db,
Lo: lo,
I18n: i18n,
})
if err != nil {
log.Fatalf("error initializing CSAT manager: %v", err)
@@ -314,7 +321,7 @@ func initWS(user *user.Manager) *ws.Hub {
}
// initTemplates inits template manager.
func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts *constants) *tmpl.Manager {
func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts *constants, i18n *i18n.I18n) *tmpl.Manager {
var (
lo = initLogger("template")
funcMap = getTmplFuncs(consts)
@@ -327,7 +334,7 @@ func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts *constants) *tmpl.
if err != nil {
log.Fatalf("error parsing web templates: %v", err)
}
m, err := tmpl.New(lo, db, webTpls, tpls, funcMap)
m, err := tmpl.New(lo, db, webTpls, tpls, funcMap, i18n)
if err != nil {
log.Fatalf("error initializing template manager: %v", err)
}
@@ -398,11 +405,12 @@ func reloadTemplates(app *App) error {
}
// initTeam inits team manager.
func initTeam(db *sqlx.DB) *team.Manager {
func initTeam(db *sqlx.DB, i18n *i18n.I18n) *team.Manager {
var lo = initLogger("team-manager")
mgr, err := team.New(team.Opts{
DB: db,
Lo: lo,
DB: db,
Lo: lo,
I18n: i18n,
})
if err != nil {
log.Fatalf("error initializing team manager: %v", err)
@@ -411,7 +419,7 @@ func initTeam(db *sqlx.DB) *team.Manager {
}
// initMedia inits media manager.
func initMedia(db *sqlx.DB) *media.Manager {
func initMedia(db *sqlx.DB, i18n *i18n.I18n) *media.Manager {
var (
store media.Store
err error
@@ -452,6 +460,7 @@ func initMedia(db *sqlx.DB) *media.Manager {
Store: store,
Lo: lo,
DB: db,
I18n: i18n,
})
if err != nil {
log.Fatalf("error initializing media: %v", err)
@@ -460,9 +469,9 @@ func initMedia(db *sqlx.DB) *media.Manager {
}
// initInbox initializes the inbox manager without registering inboxes.
func initInbox(db *sqlx.DB) *inbox.Manager {
func initInbox(db *sqlx.DB, i18n *i18n.I18n) *inbox.Manager {
var lo = initLogger("inbox-manager")
mgr, err := inbox.New(lo, db)
mgr, err := inbox.New(lo, db, i18n)
if err != nil {
log.Fatalf("error initializing inbox manager: %v", err)
}
@@ -470,11 +479,12 @@ func initInbox(db *sqlx.DB) *inbox.Manager {
}
// initAutomationEngine initializes the automation engine.
func initAutomationEngine(db *sqlx.DB) *automation.Engine {
func initAutomationEngine(db *sqlx.DB, i18n *i18n.I18n) *automation.Engine {
var lo = initLogger("automation_engine")
engine, err := automation.New(automation.Opts{
DB: db,
Lo: lo,
DB: db,
Lo: lo,
I18n: i18n,
})
if err != nil {
log.Fatalf("error initializing automation engine: %v", err)
@@ -518,7 +528,7 @@ func initNotifier() *notifier.Service {
}
// initEmailInbox initializes the email inbox.
func initEmailInbox(inboxRecord imodels.Inbox, store inbox.MessageStore) (inbox.Inbox, error) {
func initEmailInbox(inboxRecord imodels.Inbox, msgStore inbox.MessageStore, usrStore inbox.UserStore) (inbox.Inbox, error) {
var config email.Config
// Load JSON data into Koanf.
@@ -544,7 +554,7 @@ func initEmailInbox(inboxRecord imodels.Inbox, store inbox.MessageStore) (inbox.
log.Printf("WARNING: No `from` email address set for `%s` inbox: Name: `%s`", inboxRecord.Channel, inboxRecord.Name)
}
inbox, err := email.New(store, email.Opts{
inbox, err := email.New(msgStore, usrStore, email.Opts{
ID: inboxRecord.ID,
Config: config,
Lo: initLogger("email_inbox"),
@@ -560,10 +570,10 @@ func initEmailInbox(inboxRecord imodels.Inbox, store inbox.MessageStore) (inbox.
}
// initializeInboxes handles inbox initialization.
func initializeInboxes(inboxR imodels.Inbox, store inbox.MessageStore) (inbox.Inbox, error) {
func initializeInboxes(inboxR imodels.Inbox, msgStore inbox.MessageStore, usrStore inbox.UserStore) (inbox.Inbox, error) {
switch inboxR.Channel {
case "email":
return initEmailInbox(inboxR, store)
return initEmailInbox(inboxR, msgStore, usrStore)
default:
return nil, fmt.Errorf("unknown inbox channel: %s", inboxR.Channel)
}
@@ -576,8 +586,9 @@ func reloadInboxes(app *App) error {
}
// startInboxes registers the active inboxes and starts receiver for each.
func startInboxes(ctx context.Context, mgr *inbox.Manager, store inbox.MessageStore) {
mgr.SetMessageStore(store)
func startInboxes(ctx context.Context, mgr *inbox.Manager, msgStore inbox.MessageStore, usrStore inbox.UserStore) {
mgr.SetMessageStore(msgStore)
mgr.SetUserStore(usrStore)
if err := mgr.InitInboxes(initializeInboxes); err != nil {
log.Fatalf("error initializing inboxes: %v", err)
@@ -589,8 +600,8 @@ func startInboxes(ctx context.Context, mgr *inbox.Manager, store inbox.MessageSt
}
// initAuthz initializes authorization enforcer.
func initAuthz() *authz.Enforcer {
enforcer, err := authz.NewEnforcer(initLogger("authz"))
func initAuthz(i18n *i18n.I18n) *authz.Enforcer {
enforcer, err := authz.NewEnforcer(initLogger("authz"), i18n)
if err != nil {
log.Fatalf("error initializing authz: %v", err)
}
@@ -598,7 +609,7 @@ func initAuthz() *authz.Enforcer {
}
// initAuth initializes the authentication manager.
func initAuth(o *oidc.Manager, rd *redis.Client) *auth_.Auth {
func initAuth(o *oidc.Manager, rd *redis.Client, i18n *i18n.I18n) *auth_.Auth {
lo := initLogger("auth")
providers, err := buildProviders(o)
@@ -606,7 +617,8 @@ func initAuth(o *oidc.Manager, rd *redis.Client) *auth_.Auth {
log.Fatalf("error initializing auth: %v", err)
}
auth, err := auth_.New(auth_.Config{Providers: providers}, rd, lo)
secure := !ko.Bool("app.server.disable_secure_cookies")
auth, err := auth_.New(auth_.Config{Providers: providers, SecureCookies: secure}, i18n, rd, lo)
if err != nil {
log.Fatalf("error initializing auth: %v", err)
}
@@ -653,11 +665,12 @@ func buildProviders(o *oidc.Manager) ([]auth_.Provider, error) {
}
// initOIDC initializes open id connect config manager.
func initOIDC(db *sqlx.DB, settings *setting.Manager) *oidc.Manager {
func initOIDC(db *sqlx.DB, settings *setting.Manager, i18n *i18n.I18n) *oidc.Manager {
lo := initLogger("oidc")
o, err := oidc.New(oidc.Opts{
DB: db,
Lo: lo,
DB: db,
Lo: lo,
I18n: i18n,
}, settings)
if err != nil {
log.Fatalf("error initializing oidc: %v", err)
@@ -667,9 +680,11 @@ func initOIDC(db *sqlx.DB, settings *setting.Manager) *oidc.Manager {
// initI18n inits i18n.
func initI18n(fs stuffbin.FileSystem) *i18n.I18n {
file, err := fs.Get("i18n/" + cmp.Or(ko.String("app.lang"), defLang) + ".json")
fileName := cmp.Or(ko.String("app.lang"), defLang)
log.Printf("loading i18n language file: %s", fileName)
file, err := fs.Get("i18n/" + fileName + ".json")
if err != nil {
log.Fatalf("error reading i18n language file")
log.Fatalf("error reading i18n language file `%s` : %v", fileName, err)
}
i18n, err := i18n.New(file.ReadBytes())
if err != nil {
@@ -713,11 +728,12 @@ func initDB() *sqlx.DB {
}
// initRedis inits role manager.
func initRole(db *sqlx.DB) *role.Manager {
func initRole(db *sqlx.DB, i18n *i18n.I18n) *role.Manager {
var lo = initLogger("role_manager")
r, err := role.New(role.Opts{
DB: db,
Lo: lo,
DB: db,
Lo: lo,
I18n: i18n,
})
if err != nil {
log.Fatalf("error initializing role manager: %v", err)
@@ -726,10 +742,11 @@ func initRole(db *sqlx.DB) *role.Manager {
}
// initStatus inits conversation status manager.
func initStatus(db *sqlx.DB) *status.Manager {
func initStatus(db *sqlx.DB, i18n *i18n.I18n) *status.Manager {
manager, err := status.New(status.Opts{
DB: db,
Lo: initLogger("status-manager"),
DB: db,
Lo: initLogger("status-manager"),
I18n: i18n,
})
if err != nil {
log.Fatalf("error initializing status manager: %v", err)
@@ -738,10 +755,11 @@ func initStatus(db *sqlx.DB) *status.Manager {
}
// initPriority inits conversation priority manager.
func initPriority(db *sqlx.DB) *priority.Manager {
func initPriority(db *sqlx.DB, i18n *i18n.I18n) *priority.Manager {
manager, err := priority.New(priority.Opts{
DB: db,
Lo: initLogger("priority-manager"),
DB: db,
Lo: initLogger("priority-manager"),
I18n: i18n,
})
if err != nil {
log.Fatalf("error initializing priority manager: %v", err)
@@ -750,11 +768,12 @@ func initPriority(db *sqlx.DB) *priority.Manager {
}
// initAI inits AI manager.
func initAI(db *sqlx.DB) *ai.Manager {
func initAI(db *sqlx.DB, i18n *i18n.I18n) *ai.Manager {
lo := initLogger("ai")
m, err := ai.New(ai.Opts{
DB: db,
Lo: lo,
DB: db,
Lo: lo,
I18n: i18n,
})
if err != nil {
log.Fatalf("error initializing AI manager: %v", err)
@@ -763,11 +782,12 @@ func initAI(db *sqlx.DB) *ai.Manager {
}
// initSearch inits search manager.
func initSearch(db *sqlx.DB) *search.Manager {
func initSearch(db *sqlx.DB, i18n *i18n.I18n) *search.Manager {
lo := initLogger("search")
m, err := search.New(search.Opts{
DB: db,
Lo: lo,
DB: db,
Lo: lo,
I18n: i18n,
})
if err != nil {
log.Fatalf("error initializing search manager: %v", err)
@@ -775,6 +795,34 @@ func initSearch(db *sqlx.DB) *search.Manager {
return m
}
// initCustomAttribute inits custom attribute manager.
func initCustomAttribute(db *sqlx.DB, i18n *i18n.I18n) *customAttribute.Manager {
lo := initLogger("custom-attribute")
m, err := customAttribute.New(customAttribute.Opts{
DB: db,
Lo: lo,
I18n: i18n,
})
if err != nil {
log.Fatalf("error initializing custom attribute manager: %v", err)
}
return m
}
// initActivityLog inits activity log manager.
func initActivityLog(db *sqlx.DB, i18n *i18n.I18n) *activitylog.Manager {
lo := initLogger("activity-log")
m, err := activitylog.New(activitylog.Opts{
DB: db,
Lo: lo,
I18n: i18n,
})
if err != nil {
log.Fatalf("error initializing activity log manager: %v", err)
}
return m
}
// initLogger initializes a logf logger.
func initLogger(src string) *logf.Logger {
lvl, env := ko.MustString("app.log_level"), ko.MustString("app.env")

View File

@@ -25,8 +25,8 @@ func install(ctx context.Context, db *sqlx.DB, fs stuffbin.FileSystem, idempoten
// 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 password != "" && !user.IsStrongPassword(password) && !schemaInstalled {
log.Fatalf("system user password is not strong, %s", user.PasswordHint)
}
if !idempotentInstall {

View File

@@ -4,24 +4,29 @@ import (
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
"github.com/abhinavxd/libredesk/internal/envelope"
umodels "github.com/abhinavxd/libredesk/internal/user/models"
realip "github.com/ferluci/fast-realip"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
// handleLogin logs a user in.
// handleLogin logs in the user and returns the user.
func handleLogin(r *fastglue.Request) error {
var (
app = r.Context.(*App)
email = string(r.RequestCtx.PostArgs().Peek("email"))
password = r.RequestCtx.PostArgs().Peek("password")
ip = realip.FromRequest(r.RequestCtx)
)
// Verify email and password.
user, err := app.user.VerifyPassword(email, password)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Check if user is enabled.
if !user.Enabled {
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, "Your account is disabled, please contact administrator", nil))
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("user.accountDisabled"), nil))
}
// Set user availability status to online.
@@ -37,25 +42,43 @@ func handleLogin(r *fastglue.Request) error {
LastName: user.LastName,
}, r); err != nil {
app.lo.Error("error saving session", "error", err)
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("user.errorAcquiringSession"), nil))
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorSaving", "name", "{globals.terms.session}"), nil))
}
// Set CSRF cookie if not already set.
if err := app.auth.SetCSRFCookie(r); err != nil {
app.lo.Error("error setting csrf cookie", "error", err)
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("user.errorAcquiringSession"), nil))
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorSaving", "name", "{globals.terms.session}"), nil))
}
// Update last login time.
if err := app.user.UpdateLastLoginAt(user.ID); err != nil {
return sendErrorEnvelope(r, err)
}
// Insert activity log.
if err := app.activityLog.Login(user.ID, user.Email.String, ip); err != nil {
app.lo.Error("error creating login activity log", "error", err)
}
return r.SendEnvelope(user)
}
// handleLogout logs out the user and redirects to the dashboard.
func handleLogout(r *fastglue.Request) error {
var (
app = r.Context.(*App)
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
ip = realip.FromRequest(r.RequestCtx)
)
if err := app.auth.DestroySession(r); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("user.errorAcquiringSession"), nil))
// Insert activity log.
if err := app.activityLog.Logout(auser.ID, auser.Email, ip); err != nil {
app.lo.Error("error creating logout activity log", "error", err)
}
if err := app.auth.DestroySession(r); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorDestroying", "name", "{globals.terms.session}"), nil))
}
// Add no-cache headers.
r.RequestCtx.Response.Header.Add("Cache-Control",
"no-store, no-cache, must-revalidate, post-check=0, pre-check=0")

View File

@@ -2,7 +2,6 @@ package main
import (
"encoding/json"
"fmt"
"slices"
"strconv"
@@ -24,14 +23,14 @@ func handleGetMacros(r *fastglue.Request) error {
for i, m := range macros {
var actions []autoModels.RuleAction
if err := json.Unmarshal(m.Actions, &actions); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error unmarshalling macro actions", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.macroAction}"), nil, envelope.GeneralError)
}
// Set display values for actions as the value field can contain DB IDs
if err := setDisplayValues(app, actions); err != nil {
app.lo.Warn("error setting display values", "error", err)
}
if macros[i].Actions, err = json.Marshal(actions); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "marshal failed", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.macroAction}"), nil, envelope.GeneralError)
}
}
return r.SendEnvelope(macros)
@@ -44,8 +43,7 @@ func handleGetMacro(r *fastglue.Request) error {
id, err = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid macro `id`.", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
macro, err := app.macro.Get(id)
@@ -55,14 +53,14 @@ func handleGetMacro(r *fastglue.Request) error {
var actions []autoModels.RuleAction
if err := json.Unmarshal(macro.Actions, &actions); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error unmarshalling macro actions", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.macroAction}"), nil, envelope.GeneralError)
}
// Set display values for actions as the value field can contain DB IDs
if err := setDisplayValues(app, actions); err != nil {
app.lo.Warn("error setting display values", "error", err)
}
if macro.Actions, err = json.Marshal(actions); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "marshal failed", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.macroAction}"), nil, envelope.GeneralError)
}
return r.SendEnvelope(macro)
@@ -76,15 +74,14 @@ func handleCreateMacro(r *fastglue.Request) error {
)
if err := r.Decode(&macro, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), err.Error(), envelope.InputError)
}
if err := validateMacro(macro); err != nil {
if err := validateMacro(app, macro); err != nil {
return sendErrorEnvelope(r, err)
}
err := app.macro.Create(macro.Name, macro.MessageContent, macro.UserID, macro.TeamID, macro.Visibility, macro.Actions)
if err != nil {
if err := app.macro.Create(macro.Name, macro.MessageContent, macro.UserID, macro.TeamID, macro.Visibility, macro.VisibleWhen, macro.Actions); err != nil {
return sendErrorEnvelope(r, err)
}
@@ -108,11 +105,11 @@ func handleUpdateMacro(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
}
if err := validateMacro(macro); err != nil {
if err := validateMacro(app, macro); err != nil {
return sendErrorEnvelope(r, err)
}
if err = app.macro.Update(id, macro.Name, macro.MessageContent, macro.UserID, macro.TeamID, macro.Visibility, macro.Actions); err != nil {
if err = app.macro.Update(id, macro.Name, macro.MessageContent, macro.UserID, macro.TeamID, macro.Visibility, macro.VisibleWhen, macro.Actions); err != nil {
return sendErrorEnvelope(r, err)
}
@@ -122,18 +119,14 @@ func handleUpdateMacro(r *fastglue.Request) error {
// handleDeleteMacro deletes macro.
func handleDeleteMacro(r *fastglue.Request) error {
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 macro `id`.", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := app.macro.Delete(id); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("Macro deleted successfully")
return r.SendEnvelope(true)
}
// handleApplyMacro applies macro actions to a conversation.
@@ -145,7 +138,7 @@ func handleApplyMacro(r *fastglue.Request) error {
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
incomingActions = []autoModels.RuleAction{}
)
user, err := app.user.GetAgent(auser.ID)
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -156,7 +149,7 @@ func handleApplyMacro(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
if allowed, err := app.authz.EnforceConversationAccess(user, conversation); err != nil || !allowed {
return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, "Permission denied", nil))
return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil))
}
macro, err := app.macro.Get(id)
@@ -167,7 +160,7 @@ func handleApplyMacro(r *fastglue.Request) error {
// Decode incoming actions.
if err := r.Decode(&incomingActions, "json"); err != nil {
app.lo.Error("error unmashalling incoming actions", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Failed to decode incoming actions", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.macroAction}"), err.Error(), envelope.InputError)
}
// Make sure no duplicate action types are present.
@@ -175,7 +168,7 @@ func handleApplyMacro(r *fastglue.Request) error {
for _, act := range incomingActions {
if actionTypes[act.Type] {
app.lo.Warn("duplicate action types found in macro apply apply request", "action", act.Type, "user_id", user.ID)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Duplicate actions are not allowed", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("macro.duplicateActionsNotAllowed"), nil, envelope.InputError)
}
actionTypes[act.Type] = true
}
@@ -184,11 +177,11 @@ func handleApplyMacro(r *fastglue.Request) error {
for _, act := range incomingActions {
if !isMacroActionAllowed(act.Type) {
app.lo.Warn("action not allowed in macro", "action", act.Type, "user_id", user.ID)
return r.SendErrorEnvelope(fasthttp.StatusForbidden, "Action not allowed in macro", nil, envelope.PermissionError)
return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.Ts("macro.actionNotAllowed", "name", act.Type), nil, envelope.PermissionError)
}
if !hasActionPermission(act.Type, user.Permissions) {
app.lo.Warn("no permission to execute macro action", "action", act.Type, "user_id", user.ID)
return r.SendErrorEnvelope(fasthttp.StatusForbidden, "No permission to execute this macro", nil, envelope.PermissionError)
return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.T("macro.permissionDenied"), nil, envelope.PermissionError)
}
}
@@ -201,7 +194,7 @@ func handleApplyMacro(r *fastglue.Request) error {
}
if successCount == 0 {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Failed to apply macro", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("macro.couldNotApply"), nil, envelope.GeneralError)
}
// Increment usage count.
@@ -209,12 +202,12 @@ func handleApplyMacro(r *fastglue.Request) error {
if successCount < len(incomingActions) {
return r.SendJSON(fasthttp.StatusMultiStatus, map[string]interface{}{
"message": fmt.Sprintf("Macro executed with errors. %d actions succeeded out of %d", successCount, len(incomingActions)),
"message": app.i18n.T("macro.partiallyApplied"),
})
}
return r.SendJSON(fasthttp.StatusOK, map[string]interface{}{
"message": "Macro applied successfully",
"message": app.i18n.T("macro.applied"),
})
}
@@ -239,7 +232,7 @@ func setDisplayValues(app *App, actions []autoModels.RuleAction) error {
return t.Name, nil
},
autoModels.ActionAssignUser: func(id int) (string, error) {
u, err := app.user.GetAgent(id)
u, err := app.user.GetAgent(id, "")
if err != nil {
app.lo.Warn("user not found for macro action", "user_id", id)
return "", err
@@ -276,18 +269,22 @@ func setDisplayValues(app *App, actions []autoModels.RuleAction) error {
}
// validateMacro validates an incoming macro.
func validateMacro(macro models.Macro) error {
func validateMacro(app *App, macro models.Macro) error {
if macro.Name == "" {
return envelope.NewError(envelope.InputError, "Empty macro `name`", nil)
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil)
}
if len(macro.VisibleWhen) == 0 {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`visible_when`"), nil)
}
var act []autoModels.RuleAction
if err := json.Unmarshal(macro.Actions, &act); err != nil {
return envelope.NewError(envelope.InputError, "Could not parse macro actions", nil)
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.macroAction}"), nil)
}
for _, a := range act {
if len(a.Value) == 0 {
return envelope.NewError(envelope.InputError, fmt.Sprintf("Empty value for action: %s", a.Type), nil)
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", a.Type), nil)
}
}
return nil
@@ -298,7 +295,7 @@ func isMacroActionAllowed(action string) bool {
switch action {
case autoModels.ActionSendPrivateNote, autoModels.ActionReply:
return false
case autoModels.ActionAssignTeam, autoModels.ActionAssignUser, autoModels.ActionSetStatus, autoModels.ActionSetPriority, autoModels.ActionSetTags:
case autoModels.ActionAssignTeam, autoModels.ActionAssignUser, autoModels.ActionSetStatus, autoModels.ActionSetPriority, autoModels.ActionAddTags, autoModels.ActionSetTags, autoModels.ActionRemoveTags:
return true
default:
return false

View File

@@ -13,12 +13,14 @@ import (
_ "time/tzdata"
activitylog "github.com/abhinavxd/libredesk/internal/activity_log"
"github.com/abhinavxd/libredesk/internal/ai"
auth_ "github.com/abhinavxd/libredesk/internal/auth"
"github.com/abhinavxd/libredesk/internal/authz"
businesshours "github.com/abhinavxd/libredesk/internal/business_hours"
"github.com/abhinavxd/libredesk/internal/colorlog"
"github.com/abhinavxd/libredesk/internal/csat"
customAttribute "github.com/abhinavxd/libredesk/internal/custom_attribute"
"github.com/abhinavxd/libredesk/internal/macro"
notifier "github.com/abhinavxd/libredesk/internal/notification"
"github.com/abhinavxd/libredesk/internal/search"
@@ -59,33 +61,35 @@ var (
// App is the global app context which is passed and injected in the http handlers.
type App struct {
fs stuffbin.FileSystem
consts atomic.Value
auth *auth_.Auth
authz *authz.Enforcer
i18n *i18n.I18n
lo *logf.Logger
oidc *oidc.Manager
media *media.Manager
setting *setting.Manager
role *role.Manager
user *user.Manager
team *team.Manager
status *status.Manager
priority *priority.Manager
tag *tag.Manager
inbox *inbox.Manager
tmpl *template.Manager
macro *macro.Manager
conversation *conversation.Manager
automation *automation.Engine
businessHours *businesshours.Manager
sla *sla.Manager
csat *csat.Manager
view *view.Manager
ai *ai.Manager
search *search.Manager
notifier *notifier.Service
fs stuffbin.FileSystem
consts atomic.Value
auth *auth_.Auth
authz *authz.Enforcer
i18n *i18n.I18n
lo *logf.Logger
oidc *oidc.Manager
media *media.Manager
setting *setting.Manager
role *role.Manager
user *user.Manager
team *team.Manager
status *status.Manager
priority *priority.Manager
tag *tag.Manager
inbox *inbox.Manager
tmpl *template.Manager
macro *macro.Manager
conversation *conversation.Manager
automation *automation.Engine
businessHours *businesshours.Manager
sla *sla.Manager
csat *csat.Manager
view *view.Manager
ai *ai.Manager
search *search.Manager
activityLog *activitylog.Manager
notifier *notifier.Service
customAttribute *customAttribute.Manager
// Global state that stores data on an available app update.
update *AppUpdate
@@ -165,27 +169,27 @@ func main() {
rdb = initRedis()
constants = initConstants()
i18n = initI18n(fs)
csat = initCSAT(db)
oidc = initOIDC(db, settings)
status = initStatus(db)
priority = initPriority(db)
auth = initAuth(oidc, rdb)
template = initTemplate(db, fs, constants)
media = initMedia(db)
inbox = initInbox(db)
team = initTeam(db)
businessHours = initBusinessHours(db)
csat = initCSAT(db, i18n)
oidc = initOIDC(db, settings, i18n)
status = initStatus(db, i18n)
priority = initPriority(db, i18n)
auth = initAuth(oidc, rdb, i18n)
template = initTemplate(db, fs, constants, i18n)
media = initMedia(db, i18n)
inbox = initInbox(db, i18n)
team = initTeam(db, i18n)
businessHours = initBusinessHours(db, i18n)
user = initUser(i18n, db)
wsHub = initWS(user)
notifier = initNotifier()
automation = initAutomationEngine(db)
sla = initSLA(db, team, settings, businessHours, notifier, template, user)
automation = initAutomationEngine(db, i18n)
sla = initSLA(db, team, settings, businessHours, notifier, template, user, i18n)
conversation = initConversations(i18n, sla, status, priority, wsHub, notifier, db, inbox, user, team, media, settings, csat, automation, template)
autoassigner = initAutoAssigner(team, user, conversation)
)
automation.SetConversationStore(conversation)
startInboxes(ctx, inbox, conversation)
startInboxes(ctx, inbox, conversation, user)
go automation.Run(ctx, automationWorkers)
go autoassigner.Run(ctx, autoAssignInterval)
go conversation.Run(ctx, messageIncomingQWorkers, messageOutgoingQWorkers, messageOutgoingScanInterval)
@@ -197,33 +201,35 @@ func main() {
go user.MonitorAgentAvailability(ctx)
var app = &App{
lo: lo,
fs: fs,
sla: sla,
oidc: oidc,
i18n: i18n,
auth: auth,
media: media,
setting: settings,
inbox: inbox,
user: user,
team: team,
status: status,
priority: priority,
tmpl: template,
notifier: notifier,
consts: atomic.Value{},
conversation: conversation,
automation: automation,
businessHours: businessHours,
authz: initAuthz(),
view: initView(db),
csat: initCSAT(db),
search: initSearch(db),
role: initRole(db),
tag: initTag(db),
macro: initMacro(db),
ai: initAI(db),
lo: lo,
fs: fs,
sla: sla,
oidc: oidc,
i18n: i18n,
auth: auth,
media: media,
setting: settings,
inbox: inbox,
user: user,
team: team,
status: status,
priority: priority,
tmpl: template,
notifier: notifier,
consts: atomic.Value{},
conversation: conversation,
automation: automation,
businessHours: businessHours,
activityLog: initActivityLog(db, i18n),
customAttribute: initCustomAttribute(db, i18n),
authz: initAuthz(i18n),
view: initView(db),
csat: initCSAT(db, i18n),
search: initSearch(db, i18n),
role: initRole(db, i18n),
tag: initTag(db, i18n),
macro: initMacro(db, i18n),
ai: initAI(db, i18n),
}
app.consts.Store(constants)

View File

@@ -24,6 +24,7 @@ const (
thumbPrefix = "thumb_"
)
// handleMediaUpload handles media uploads.
func handleMediaUpload(r *fastglue.Request) error {
var (
app = r.Context.(*App)
@@ -33,19 +34,19 @@ func handleMediaUpload(r *fastglue.Request) error {
form, err := r.RequestCtx.MultipartForm()
if err != nil {
app.lo.Error("error parsing form data.", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error parsing data", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.GeneralError)
}
files, ok := form.File["files"]
if !ok || len(files) == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "File not found", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.file}"), nil, envelope.InputError)
}
fileHeader := files[0]
file, err := fileHeader.Open()
if err != nil {
app.lo.Error("error reading uploaded file", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error reading file", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorReading", "name", "{globals.terms.file}"), nil, envelope.GeneralError)
}
defer file.Close()
@@ -74,15 +75,15 @@ func handleMediaUpload(r *fastglue.Request) error {
if bytesToMegabytes(srcFileSize) > float64(consts.MaxFileUploadSizeMB) {
app.lo.Error("error: uploaded file size is larger than max allowed", "size", bytesToMegabytes(srcFileSize), "max_allowed", consts.MaxFileUploadSizeMB)
return r.SendErrorEnvelope(
http.StatusRequestEntityTooLarge,
fmt.Sprintf("File size is too large. Please upload file lesser than %d MB", consts.MaxFileUploadSizeMB),
fasthttp.StatusRequestEntityTooLarge,
app.i18n.Ts("media.fileSizeTooLarge", "size", fmt.Sprintf("%dMB", consts.MaxFileUploadSizeMB)),
nil,
envelope.GeneralError,
)
}
if !slices.Contains(consts.AllowedUploadFileExtensions, "*") && !slices.Contains(consts.AllowedUploadFileExtensions, srcExt) {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "File type not allowed", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("media.fileTypeNotAllowed"), nil, envelope.InputError)
}
// Delete files on any error.
@@ -102,11 +103,10 @@ func handleMediaUpload(r *fastglue.Request) error {
thumbFile, err := image.CreateThumb(image.DefThumbSize, file)
if err != nil {
app.lo.Error("error creating thumb image", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error creating image thumbnail", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.thumbnail}"), nil, envelope.GeneralError)
}
thumbName, err = app.media.Upload(thumbName, srcContentType, thumbFile)
if err != nil {
app.lo.Error("error uploading thumbnail", "error", err)
return sendErrorEnvelope(r, err)
}
@@ -116,7 +116,7 @@ func handleMediaUpload(r *fastglue.Request) error {
if err != nil {
cleanUp = true
app.lo.Error("error getting image dimensions", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error uploading file", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorUploading", "name", "{globals.terms.media}"), nil, envelope.GeneralError)
}
meta, _ = json.Marshal(map[string]interface{}{
"width": width,
@@ -129,7 +129,7 @@ func handleMediaUpload(r *fastglue.Request) error {
if err != nil {
cleanUp = true
app.lo.Error("error uploading file", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error uploading file", nil, envelope.GeneralError)
return sendErrorEnvelope(r, err)
}
// Insert in DB.
@@ -137,7 +137,7 @@ func handleMediaUpload(r *fastglue.Request) error {
if err != nil {
cleanUp = true
app.lo.Error("error inserting metadata into database", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error inserting media", nil, envelope.GeneralError)
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(media)
}
@@ -150,7 +150,7 @@ func handleServeMedia(r *fastglue.Request) error {
uuid = r.RequestCtx.UserValue("uuid").(string)
)
user, err := app.user.GetAgent(auser.ID)
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -164,7 +164,6 @@ func handleServeMedia(r *fastglue.Request) error {
// Check if the user has permission to access the linked model.
allowed, err := app.authz.EnforceMediaAccess(user, media.Model.String)
if err != nil {
app.lo.Error("error checking media permission", "error", err, "model", media.Model.String, "model_id", media.ModelID)
return sendErrorEnvelope(r, err)
}
@@ -181,7 +180,7 @@ func handleServeMedia(r *fastglue.Request) error {
}
if !allowed {
return r.SendErrorEnvelope(http.StatusUnauthorized, "Permission denied", nil, envelope.PermissionError)
return r.SendErrorEnvelope(http.StatusUnauthorized, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.UnauthorizedError)
}
consts := app.consts.Load().(*constants)
switch consts.UploadProvider {
@@ -193,6 +192,7 @@ func handleServeMedia(r *fastglue.Request) error {
return nil
}
// bytesToMegabytes converts bytes to megabytes.
func bytesToMegabytes(bytes int64) float64 {
return float64(bytes) / 1024 / 1024
}

View File

@@ -15,6 +15,7 @@ type messageReq struct {
Attachments []int `json:"attachments"`
Message string `json:"message"`
Private bool `json:"private"`
To []string `json:"to"`
CC []string `json:"cc"`
BCC []string `json:"bcc"`
}
@@ -30,7 +31,7 @@ func handleGetMessages(r *fastglue.Request) error {
total = 0
)
user, err := app.user.GetAgent(auser.ID)
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -73,7 +74,7 @@ func handleGetMessage(r *fastglue.Request) error {
cuuid = r.RequestCtx.UserValue("cuuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.GetAgent(auser.ID)
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -108,7 +109,7 @@ func handleRetryMessage(r *fastglue.Request) error {
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.GetAgent(auser.ID)
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -131,16 +132,15 @@ func handleSendMessage(r *fastglue.Request) error {
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
cuuid = r.RequestCtx.UserValue("cuuid").(string)
media = []medModels.Media{}
req = messageReq{}
)
user, err := app.user.GetAgent(auser.ID)
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
// Check permission
// Check access to conversation.
conv, err := enforceConversationAccess(app, cuuid, user)
if err != nil {
return sendErrorEnvelope(r, err)
@@ -148,14 +148,16 @@ func handleSendMessage(r *fastglue.Request) error {
if err := r.Decode(&req, "json"); err != nil {
app.lo.Error("error unmarshalling message request", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "error decoding request", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
// Prepare attachments.
var media = make([]medModels.Media, 0, len(req.Attachments))
for _, id := range req.Attachments {
m, err := app.media.Get(id, "")
if err != nil {
app.lo.Error("error fetching media", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error fetching media", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.media}"), nil, envelope.GeneralError)
}
media = append(media, m)
}
@@ -165,12 +167,11 @@ func handleSendMessage(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
} else {
if err := app.conversation.SendReply(media, conv.InboxID, user.ID, cuuid, req.Message, req.CC, req.BCC, map[string]any{} /**meta**/); err != nil {
if err := app.conversation.SendReply(media, conv.InboxID, user.ID, cuuid, req.Message, req.To, req.CC, req.BCC, map[string]any{} /**meta**/); err != nil {
return sendErrorEnvelope(r, err)
}
// Evaluate automation rules.
app.automation.EvaluateConversationUpdateRules(cuuid, models.EventConversationMessageOutgoing)
}
return r.SendEnvelope("Message sent successfully")
return r.SendEnvelope(true)
}

View File

@@ -11,8 +11,8 @@ import (
"github.com/zerodha/simplesessions/v3"
)
// tryAuth is a middleware that attempts to authenticate the user and add them to the context
// but doesn't enforce authentication. Handlers can check if user exists in context optionally.
// tryAuth attempts to authenticate the user and add them to the context but doesn't enforce authentication.
// Handlers can check if user exists in context optionally.
func tryAuth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
return func(r *fastglue.Request) error {
app := r.Context.(*App)
@@ -24,7 +24,7 @@ func tryAuth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
}
// Try to get user.
user, err := app.user.GetAgent(userSession.ID)
user, err := app.user.GetAgent(userSession.ID, "")
if err != nil {
return handler(r)
}
@@ -41,7 +41,7 @@ func tryAuth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
}
}
// auth makes sure the user is logged in.
// auth validates the session and adds the user to the request context.
func auth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
return func(r *fastglue.Request) error {
var app = r.Context.(*App)
@@ -50,11 +50,11 @@ func auth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
userSession, err := app.auth.ValidateSession(r)
if err != nil || userSession.ID <= 0 {
app.lo.Error("error validating session", "error", err)
return r.SendErrorEnvelope(http.StatusUnauthorized, "Invalid or expired session", nil, envelope.PermissionError)
return r.SendErrorEnvelope(http.StatusUnauthorized, app.i18n.T("auth.invalidOrExpiredSession"), nil, envelope.GeneralError)
}
// Set user in the request context.
user, err := app.user.GetAgent(userSession.ID)
user, err := app.user.GetAgent(userSession.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -69,7 +69,8 @@ func auth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
}
}
// perm does session validation, CSRF, and permission enforcement.
// perm matches the CSRF token and checks if the user has the required permission to access the endpoint.
// and sets the user in the request context.
func perm(handler fastglue.FastRequestHandler, perm string) fastglue.FastRequestHandler {
return func(r *fastglue.Request) error {
var (
@@ -78,20 +79,21 @@ func perm(handler fastglue.FastRequestHandler, perm string) fastglue.FastRequest
hdrToken = string(r.RequestCtx.Request.Header.Peek("X-CSRFTOKEN"))
)
// Match CSRF token from cookie and header.
if cookieToken == "" || hdrToken == "" || cookieToken != hdrToken {
app.lo.Error("csrf token mismatch", "cookie_token", cookieToken, "header_token", hdrToken)
return r.SendErrorEnvelope(http.StatusForbidden, "Invalid CSRF token", nil, envelope.PermissionError)
return r.SendErrorEnvelope(http.StatusForbidden, app.i18n.T("auth.csrfTokenMismatch"), nil, envelope.PermissionError)
}
// Validate session and fetch user.
sessUser, err := app.auth.ValidateSession(r)
if err != nil || sessUser.ID <= 0 {
app.lo.Error("error validating session", "error", err)
return r.SendErrorEnvelope(http.StatusUnauthorized, "Invalid or expired session", nil, envelope.PermissionError)
return r.SendErrorEnvelope(http.StatusUnauthorized, app.i18n.T("auth.invalidOrExpiredSession"), nil, envelope.GeneralError)
}
// Get user from DB.
user, err := app.user.GetAgent(sessUser.ID)
user, err := app.user.GetAgent(sessUser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -101,21 +103,21 @@ func perm(handler fastglue.FastRequestHandler, perm string) fastglue.FastRequest
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)
return r.SendErrorEnvelope(http.StatusUnauthorized, app.i18n.T("user.accountDisabled"), nil, envelope.PermissionError)
}
// Split the permission string into object and action and enforce it.
parts := strings.Split(perm, ":")
if len(parts) != 2 {
return r.SendErrorEnvelope(http.StatusInternalServerError, "Invalid permission format", nil, envelope.GeneralError)
return r.SendErrorEnvelope(http.StatusInternalServerError, app.i18n.Ts("globals.messages.invalid", "name", "{globals.terms.permission}"), nil, envelope.GeneralError)
}
object, action := parts[0], parts[1]
ok, err := app.authz.Enforce(user, object, action)
if err != nil {
return r.SendErrorEnvelope(http.StatusInternalServerError, "Error checking permissions", nil, envelope.GeneralError)
return r.SendErrorEnvelope(http.StatusInternalServerError, app.i18n.Ts("globals.messages.errorChecking", "name", "{globals.terms.permission}"), nil, envelope.GeneralError)
}
if !ok {
return r.SendErrorEnvelope(http.StatusForbidden, "Permission denied", nil, envelope.PermissionError)
return r.SendErrorEnvelope(http.StatusForbidden, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.PermissionError)
}
// Set user in the request context.
@@ -141,7 +143,7 @@ func authPage(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
// 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)
return r.SendErrorEnvelope(http.StatusUnauthorized, app.i18n.Ts("globals.messages.errorValidating", "name", "{globals.terms.session}"), nil, envelope.GeneralError)
}
if err := app.auth.DestroySession(r); err != nil {
app.lo.Error("error destroying session", "error", err)
@@ -172,7 +174,7 @@ func notAuthPage(handler fastglue.FastRequestHandler) fastglue.FastRequestHandle
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, clear cookies and try again", nil, envelope.PermissionError)
return r.SendErrorEnvelope(http.StatusUnauthorized, app.i18n.T("auth.invalidOrExpiredSessionClearCookie"), nil, envelope.GeneralError)
}
if user.ID != 0 {

View File

@@ -41,7 +41,7 @@ func handleGetOIDC(r *fastglue.Request) error {
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)
app.i18n.Ts("globals.messages.invalid", "name", "OIDC `id`"), nil, envelope.InputError)
}
o, err := app.oidc.Get(id, false)
if err != nil {
@@ -59,7 +59,7 @@ func handleTestOIDC(r *fastglue.Request) error {
if err := app.auth.TestProvider(providerURL); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("OIDC provider discovered successfully")
return r.SendEnvelope(true)
}
// handleCreateOIDC creates a new OIDC record.
@@ -69,7 +69,7 @@ func handleCreateOIDC(r *fastglue.Request) error {
req = models.OIDC{}
)
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Bad request", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.GeneralError)
}
if err := app.oidc.Create(req); err != nil {
@@ -78,7 +78,7 @@ func handleCreateOIDC(r *fastglue.Request) error {
// 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.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.couldNotReload", "name", "OIDC"), nil, envelope.GeneralError)
}
return r.SendEnvelope("OIDC created successfully")
}
@@ -91,12 +91,11 @@ func handleUpdateOIDC(r *fastglue.Request) error {
)
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)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "OIDC `id`"), nil, envelope.InputError)
}
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Bad request", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.GeneralError)
}
if err = app.oidc.Update(id, req); err != nil {
@@ -105,9 +104,9 @@ func handleUpdateOIDC(r *fastglue.Request) error {
// 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.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.couldNotReload", "name", "OIDC"), nil, envelope.GeneralError)
}
return r.SendEnvelope("OIDC updated successfully")
return r.SendEnvelope(true)
}
// handleDeleteOIDC deletes an OIDC record.
@@ -115,11 +114,10 @@ func handleDeleteOIDC(r *fastglue.Request) error {
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)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "OIDC `id`"), nil, envelope.InputError)
}
if err = app.oidc.Delete(id); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("OIDC deleted successfully")
return r.SendEnvelope(true)
}

View File

@@ -14,11 +14,11 @@ func handleGetRoles(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
agents, err := app.role.GetAll()
roles, err := app.role.GetAll()
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(agents)
return r.SendEnvelope(roles)
}
// handleGetRole returns a single role
@@ -43,7 +43,7 @@ func handleDeleteRole(r *fastglue.Request) error {
if err := app.role.Delete(id); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("Role deleted successfully")
return r.SendEnvelope(true)
}
// handleCreateRole creates a new role
@@ -53,12 +53,12 @@ func handleCreateRole(r *fastglue.Request) error {
req = models.Role{}
)
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if err := app.role.Create(req); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("Role created successfully")
return r.SendEnvelope(true)
}
// handleUpdateRole updates a role
@@ -69,10 +69,10 @@ func handleUpdateRole(r *fastglue.Request) error {
req = models.Role{}
)
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if err := app.role.Update(id, req);err != nil {
if err := app.role.Update(id, req); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("Role updated successfully")
return r.SendEnvelope(true)
}

View File

@@ -1,6 +1,8 @@
package main
import (
"fmt"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/zerodha/fastglue"
)
@@ -11,52 +13,45 @@ const (
// handleSearchConversations searches conversations based on the query.
func handleSearchConversations(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))
app := r.Context.(*App)
wrapper := func(query string) (interface{}, error) {
return app.search.Conversations(query)
}
conversations, err := app.search.Conversations(q)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(conversations)
return handleSearch(r, wrapper)
}
// handleSearchMessages searches messages based on the query.
func handleSearchMessages(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))
app := r.Context.(*App)
wrapper := func(query string) (interface{}, error) {
return app.search.Messages(query)
}
messages, err := app.search.Messages(q)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(messages)
return handleSearch(r, wrapper)
}
// handleSearchContacts searches contacts based on the query.
func handleSearchContacts(r *fastglue.Request) error {
app := r.Context.(*App)
wrapper := func(query string) (interface{}, error) {
return app.search.Contacts(query)
}
return handleSearch(r, wrapper)
}
// handleSearch searches for the given query using the provided search function.
func handleSearch(r *fastglue.Request, searchFunc func(string) (interface{}, error)) 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))
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("search.minQueryLength", "length", fmt.Sprintf("%d", minSearchQueryLength)), nil))
}
contacts, err := app.search.Contacts(q)
results, err := searchFunc(q)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(contacts)
}
return r.SendEnvelope(results)
}

View File

@@ -12,7 +12,7 @@ import (
"github.com/zerodha/fastglue"
)
// handleGetGeneralSettings fetches general settings.
// handleGetGeneralSettings fetches general settings, this endpoint is not behind auth as it has no sensitive data and is required for the app to function.
func handleGetGeneralSettings(r *fastglue.Request) error {
var (
app = r.Context.(*App)
@@ -25,7 +25,7 @@ func handleGetGeneralSettings(r *fastglue.Request) error {
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))
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorFetching", "name", app.i18n.T("globals.terms.setting")), 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
@@ -42,7 +42,7 @@ func handleUpdateGeneralSettings(r *fastglue.Request) error {
)
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Bad request", nil, "")
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.badRequest"), nil, envelope.InputError)
}
// Remove any trailing slash `/` from the root url.
@@ -53,12 +53,12 @@ func handleUpdateGeneralSettings(r *fastglue.Request) error {
}
// Reload the settings and templates.
if err := reloadSettings(app); err != nil {
return envelope.NewError(envelope.GeneralError, "Could not reload settings, Please restart the app.", nil)
return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.couldNotReload", "name", app.i18n.T("globals.terms.setting")), nil)
}
if err := reloadTemplates(app); err != nil {
return envelope.NewError(envelope.GeneralError, "Could not reload settings, Please restart the app.", nil)
return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.couldNotReload", "name", app.i18n.T("globals.terms.setting")), nil)
}
return r.SendEnvelope("Settings updated successfully")
return r.SendEnvelope(true)
}
// handleGetEmailNotificationSettings fetches email notification settings.
@@ -75,7 +75,7 @@ func handleGetEmailNotificationSettings(r *fastglue.Request) error {
// Unmarshal and filter out password.
if err := json.Unmarshal(out, &notif); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, "Error fetching settings", nil))
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorFetching", "name", app.i18n.T("globals.terms.setting")), nil))
}
if notif.Password != "" {
notif.Password = strings.Repeat(stringutil.PasswordDummy, 10)
@@ -92,7 +92,7 @@ func handleUpdateEmailNotificationSettings(r *fastglue.Request) error {
)
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Bad request", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.badRequest"), nil, envelope.InputError)
}
out, err := app.setting.GetByPrefix("notification.email")
@@ -101,12 +101,12 @@ func handleUpdateEmailNotificationSettings(r *fastglue.Request) error {
}
if err := json.Unmarshal(out, &cur); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, "Error updating settings", nil))
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorUpdating", "name", app.i18n.T("globals.terms.setting")), 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, make sure it's a valid email address in the format `Name <mail@example.com>`", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.invalidFromAddress"), nil, envelope.InputError)
}
if req.Password == "" {
@@ -118,5 +118,5 @@ func handleUpdateEmailNotificationSettings(r *fastglue.Request) error {
}
// 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.")
return r.SendEnvelope(true)
}

View File

@@ -29,12 +29,12 @@ func handleGetSLA(r *fastglue.Request) error {
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid SLA `id`.", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
sla, err := app.sla.Get(id)
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, "")
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(sla)
}
@@ -47,14 +47,14 @@ func handleCreateSLA(r *fastglue.Request) error {
)
if err := r.Decode(&sla, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), err.Error(), envelope.InputError)
}
if err := validateSLA(&sla); err != nil {
if err := validateSLA(app, &sla); err != nil {
return sendErrorEnvelope(r, err)
}
if err := app.sla.Create(sla.Name, sla.Description, sla.FirstResponseTime, sla.ResolutionTime, sla.Notifications); err != nil {
if err := app.sla.Create(sla.Name, sla.Description, sla.FirstResponseTime, sla.ResolutionTime, sla.NextResponseTime, sla.Notifications); err != nil {
return sendErrorEnvelope(r, err)
}
@@ -70,22 +70,22 @@ func handleUpdateSLA(r *fastglue.Request) error {
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid SLA `id`.", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := r.Decode(&sla, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), err.Error(), envelope.InputError)
}
if err := validateSLA(&sla); err != nil {
if err := validateSLA(app, &sla); err != nil {
return sendErrorEnvelope(r, err)
}
if err := app.sla.Update(id, sla.Name, sla.Description, sla.FirstResponseTime, sla.ResolutionTime, sla.Notifications); err != nil {
if err := app.sla.Update(id, sla.Name, sla.Description, sla.FirstResponseTime, sla.ResolutionTime, sla.NextResponseTime, sla.Notifications); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("SLA updated successfully.")
return r.SendEnvelope(true)
}
// handleDeleteSLA deletes the SLA with the given ID.
@@ -95,7 +95,7 @@ func handleDeleteSLA(r *fastglue.Request) error {
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid SLA `id`.", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err = app.sla.Delete(id); err != nil {
@@ -106,53 +106,81 @@ func handleDeleteSLA(r *fastglue.Request) error {
}
// validateSLA validates the SLA policy and returns an envelope.Error if any validation fails.
func validateSLA(sla *smodels.SLAPolicy) error {
func validateSLA(app *App, sla *smodels.SLAPolicy) error {
if sla.Name == "" {
return envelope.NewError(envelope.InputError, "SLA `name` is required", nil)
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil)
}
if sla.FirstResponseTime == "" {
return envelope.NewError(envelope.InputError, "SLA `first_response_time` is required", nil)
}
if sla.ResolutionTime == "" {
return envelope.NewError(envelope.InputError, "SLA `resolution_time` is required", nil)
if sla.FirstResponseTime.String == "" && sla.NextResponseTime.String == "" && sla.ResolutionTime.String == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "At least one of `first_response_time`, `next_response_time`, or `resolution_time` must be provided."), nil)
}
// Validate notifications if any
// Validate notifications if any.
for _, n := range sla.Notifications {
if n.Type == "" {
return envelope.NewError(envelope.InputError, "SLA notification `type` is required", nil)
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`type`"), nil)
}
if n.TimeDelayType == "" {
return envelope.NewError(envelope.InputError, "SLA notification `time_delay_type` is required", nil)
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`time_delay_type`"), nil)
}
if n.Metric == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`metric`"), nil)
}
if n.TimeDelayType != "immediately" {
if n.TimeDelay == "" {
return envelope.NewError(envelope.InputError, "SLA notification `time_delay` is required", nil)
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`time_delay`"), nil)
}
// Validate time delay duration.
td, err := time.ParseDuration(n.TimeDelay)
if err != nil {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`time_delay`"), nil)
}
if td.Minutes() < 1 {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`time_delay`"), nil)
}
}
if len(n.Recipients) == 0 {
return envelope.NewError(envelope.InputError, "SLA notification `recipients` is required", nil)
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`recipients`"), nil)
}
}
// Validate time duration strings
frt, err := time.ParseDuration(sla.FirstResponseTime)
if err != nil {
return envelope.NewError(envelope.InputError, "Invalid `first_response_time` duration", nil)
}
if frt.Minutes() < 1 {
return envelope.NewError(envelope.InputError, "`first_response_time` should be greater than 1 minute", nil)
// Validate first response time duration string if not empty.
if sla.FirstResponseTime.String != "" {
frt, err := time.ParseDuration(sla.FirstResponseTime.String)
if err != nil {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`first_response_time`"), nil)
}
if frt.Minutes() < 1 {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`first_response_time`"), nil)
}
}
rt, err := time.ParseDuration(sla.ResolutionTime)
if err != nil {
return envelope.NewError(envelope.InputError, "Invalid `resolution_time` duration", nil)
// Validate resolution time duration string if not empty.
if sla.ResolutionTime.String != "" {
rt, err := time.ParseDuration(sla.ResolutionTime.String)
if err != nil {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`resolution_time`"), nil)
}
if rt.Minutes() < 1 {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`resolution_time`"), nil)
}
// Compare with first response time if both are present.
if sla.FirstResponseTime.String != "" {
frt, _ := time.ParseDuration(sla.FirstResponseTime.String)
if frt > rt {
return envelope.NewError(envelope.InputError, app.i18n.T("sla.firstResponseTimeAfterResolution"), nil)
}
}
}
if rt.Minutes() < 1 {
return envelope.NewError(envelope.InputError, "`resolution_time` should be greater than 1 minute", nil)
}
if frt > rt {
return envelope.NewError(envelope.InputError, "`first_response_time` should be less than `resolution_time`", nil)
// Validate next response time duration string if not empty.
if sla.NextResponseTime.String != "" {
nrt, err := time.ParseDuration(sla.NextResponseTime.String)
if err != nil {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`next_response_time`"), nil)
}
if nrt.Minutes() < 1 {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`next_response_time`"), nil)
}
}
return nil

View File

@@ -26,11 +26,11 @@ func handleCreateStatus(r *fastglue.Request) error {
status = cmodels.Status{}
)
if err := r.Decode(&status, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), err.Error(), envelope.InputError)
}
if status.Name == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty status `Name`", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
}
err := app.status.Create(status.Name)
@@ -46,20 +46,13 @@ func handleDeleteStatus(r *fastglue.Request) error {
app = r.Context.(*App)
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid status `id`.", nil, envelope.InputError)
if err != nil || id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty status `ID`", nil, envelope.InputError)
}
err = app.status.Delete(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
@@ -70,16 +63,15 @@ func handleUpdateStatus(r *fastglue.Request) error {
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid status `id`.", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := r.Decode(&status, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), err.Error(), envelope.InputError)
}
if status.Name == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty status `Name`", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
}
err = app.status.Update(id, status.Name)

View File

@@ -9,81 +9,76 @@ import (
"github.com/zerodha/fastglue"
)
// handleGetTags returns all tags from the database.
func handleGetTags(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
t, err := app.tag.GetAll()
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, "")
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(t)
}
// handleCreateTag creates a new tag in the database.
func handleCreateTag(r *fastglue.Request) error {
var (
app = r.Context.(*App)
tag = tmodels.Tag{}
)
if err := r.Decode(&tag, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), err.Error(), envelope.InputError)
}
if tag.Name == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty tag `Name`", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
}
err := app.tag.Create(tag.Name)
if err != nil {
if err := app.tag.Create(tag.Name); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// handleDeleteTag deletes a tag from the database.
func handleDeleteTag(r *fastglue.Request) error {
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 tag `id`.", nil, envelope.InputError)
if err != nil || id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty tag `ID`", nil, envelope.InputError)
}
err = app.tag.Delete(id)
if err != nil {
if err = app.tag.Delete(id); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// handleUpdateTag updates an existing tag in the database.
func handleUpdateTag(r *fastglue.Request) error {
var (
app = r.Context.(*App)
tag = tmodels.Tag{}
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid tag `id`.", nil, envelope.InputError)
if err != nil || id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := r.Decode(&tag, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), err.Error(), envelope.InputError)
}
if tag.Name == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty tag `Name`", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
}
err = app.tag.Update(id, tag.Name)
if err != nil {
if err = app.tag.Update(id, tag.Name); err != nil {
return sendErrorEnvelope(r, err)
}

View File

@@ -40,7 +40,7 @@ func handleGetTeam(r *fastglue.Request) error {
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id < 1 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid team `id`.", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
team, err := app.team.Get(id)
if err != nil {
@@ -64,7 +64,7 @@ func handleCreateTeam(r *fastglue.Request) error {
if err := app.team.Create(name, timezone, conversationAssignmentType, null.NewInt(businessHrsID, businessHrsID != 0), null.NewInt(slaPolicyID, slaPolicyID != 0), emoji, maxAutoAssignedConversations); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("Team created successfully.")
return r.SendEnvelope(true)
}
// handleUpdateTeam updates an existing team.
@@ -86,7 +86,7 @@ func handleUpdateTeam(r *fastglue.Request) error {
if err := app.team.Update(id, name, timezone, conversationAssignmentType, null.NewInt(businessHrsID, businessHrsID != 0), null.NewInt(slaPolicyID, slaPolicyID != 0), emoji, maxAutoAssignedConversations); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("Team updated successfully.")
return r.SendEnvelope(true)
}
// handleDeleteTeam deletes a team
@@ -96,12 +96,11 @@ func handleDeleteTeam(r *fastglue.Request) error {
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid team `id`.", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
err = app.team.Delete(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("Team deleted successfully.")
return r.SendEnvelope(true)
}

View File

@@ -16,7 +16,7 @@ func handleGetTemplates(r *fastglue.Request) error {
typ = string(r.RequestCtx.QueryArgs().Peek("type"))
)
if typ == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `type`.", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`type`"), nil, envelope.InputError)
}
t, err := app.tmpl.GetAll(typ)
if err != nil {
@@ -32,8 +32,7 @@ func handleGetTemplate(r *fastglue.Request) error {
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid template `id`.", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
t, err := app.tmpl.Get(id)
if err != nil {
@@ -49,7 +48,10 @@ func handleCreateTemplate(r *fastglue.Request) error {
req = models.Template{}
)
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Bad request", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if req.Name == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
}
if err := app.tmpl.Create(req); err != nil {
return sendErrorEnvelope(r, err)
@@ -69,7 +71,10 @@ func handleUpdateTemplate(r *fastglue.Request) error {
"Invalid template `id`.", nil, envelope.InputError)
}
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Bad request", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if req.Name == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
}
if err = app.tmpl.Update(id, req); err != nil {
return sendErrorEnvelope(r, err)
@@ -89,7 +94,7 @@ func handleDeleteTemplate(r *fastglue.Request) error {
"Invalid template `id`.", nil, envelope.InputError)
}
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Bad request", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if err = app.tmpl.Delete(id); err != nil {
return sendErrorEnvelope(r, err)

View File

@@ -33,6 +33,7 @@ var migList = []migFunc{
{"v0.3.0", migrations.V0_3_0},
{"v0.4.0", migrations.V0_4_0},
{"v0.5.0", migrations.V0_5_0},
{"v0.6.0", migrations.V0_6_0},
}
// upgrade upgrades the database to the current version by running SQL migration files

View File

@@ -2,7 +2,7 @@ package main
import (
"fmt"
"net/http"
"mime/multipart"
"path/filepath"
"slices"
"strconv"
@@ -16,92 +16,112 @@ import (
"github.com/abhinavxd/libredesk/internal/stringutil"
tmpl "github.com/abhinavxd/libredesk/internal/template"
"github.com/abhinavxd/libredesk/internal/user/models"
realip "github.com/ferluci/fast-realip"
"github.com/valyala/fasthttp"
"github.com/volatiletech/null/v9"
"github.com/zerodha/fastglue"
)
const (
maxAvatarSizeMB = 20
maxAvatarSizeMB = 2
)
// handleGetUsers returns all users.
func handleGetUsers(r *fastglue.Request) error {
// handleGetAgents returns all agents.
func handleGetAgents(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
agents, err := app.user.GetAll()
agents, err := app.user.GetAgents()
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, "")
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(agents)
}
// handleGetUsersCompact returns all users in a compact format.
func handleGetUsersCompact(r *fastglue.Request) error {
// handleGetAgentsCompact returns all agents in a compact format.
func handleGetAgentsCompact(r *fastglue.Request) error {
var app = r.Context.(*App)
agents, err := app.user.GetAllCompact()
agents, err := app.user.GetAgentsCompact()
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, "")
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(agents)
}
// handleGetUser returns a user.
func handleGetUser(r *fastglue.Request) error {
// handleGetAgent returns an agent.
func handleGetAgent(r *fastglue.Request) error {
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 user `id`.", nil, envelope.InputError)
if err != nil || id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
user, err := app.user.GetAgent(id)
agent, err := app.user.GetAgent(id, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(user)
return r.SendEnvelope(agent)
}
// handleUpdateUserAvailability updates the current user availability.
func handleUpdateUserAvailability(r *fastglue.Request) error {
// handleUpdateAgentAvailability updates the current agent availability.
func handleUpdateAgentAvailability(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
status = string(r.RequestCtx.PostArgs().Peek("status"))
ip = realip.FromRequest(r.RequestCtx)
)
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.GetAgent(auser.ID)
agent, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
teams, err := app.team.GetUserTeams(user.ID)
// Same status?
if agent.AvailabilityStatus == status {
return r.SendEnvelope(true)
}
// Update availability status.
if err := app.user.UpdateAvailability(auser.ID, status); err != nil {
return sendErrorEnvelope(r, err)
}
// Skip activity log if agent returns online from away (to avoid spam).
if !(agent.AvailabilityStatus == models.Away && status == models.Online) {
if err := app.activityLog.UserAvailability(auser.ID, auser.Email, status, ip, "", 0); err != nil {
app.lo.Error("error creating activity log", "error", err)
}
}
return r.SendEnvelope(true)
}
// handleGetCurrentAgentTeams returns the teams of an agent.
func handleGetCurrentAgentTeams(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
agent, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
teams, err := app.team.GetUserTeams(agent.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(teams)
}
// handleUpdateCurrentUser updates the current user.
func handleUpdateCurrentUser(r *fastglue.Request) error {
// handleUpdateCurrentAgent updates the current agent.
func handleUpdateCurrentAgent(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.GetAgent(auser.ID)
agent, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -109,94 +129,40 @@ func handleUpdateCurrentUser(r *fastglue.Request) error {
form, err := r.RequestCtx.MultipartForm()
if err != nil {
app.lo.Error("error parsing form data", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error parsing data", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.GeneralError)
}
files, ok := form.File["files"]
// Upload avatar?
if ok && len(files) > 0 {
fileHeader := files[0]
file, err := fileHeader.Open()
if err != nil {
app.lo.Error("error reading uploaded", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error reading file", nil, envelope.GeneralError)
}
defer file.Close()
// Sanitize filename.
srcFileName := stringutil.SanitizeFilename(fileHeader.Filename)
srcContentType := fileHeader.Header.Get("Content-Type")
srcFileSize := fileHeader.Size
srcExt := strings.TrimPrefix(strings.ToLower(filepath.Ext(srcFileName)), ".")
if !slices.Contains(image.Exts, srcExt) {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "File type is not an image", nil, envelope.InputError)
}
// Check file size
if bytesToMegabytes(srcFileSize) > maxAvatarSizeMB {
app.lo.Error("error uploaded file size is larger than max allowed", "size", bytesToMegabytes(srcFileSize), "max_allowed", maxAvatarSizeMB)
return r.SendErrorEnvelope(
http.StatusRequestEntityTooLarge,
fmt.Sprintf("File size is too large. Please upload file lesser than %d MB", maxAvatarSizeMB),
nil,
envelope.GeneralError,
)
}
// Reset ptr.
file.Seek(0, 0)
linkedModel := null.StringFrom(mmodels.ModelUser)
linkedID := null.IntFrom(user.ID)
disposition := null.NewString("", false)
contentID := ""
meta := []byte("{}")
media, err := app.media.UploadAndInsert(srcFileName, srcContentType, contentID, linkedModel, linkedID, file, int(srcFileSize), disposition, meta)
if err != nil {
app.lo.Error("error uploading file", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error uploading file", nil, envelope.GeneralError)
}
// Delete current avatar.
if user.AvatarURL.Valid {
fileName := filepath.Base(user.AvatarURL.String)
app.media.Delete(fileName)
}
// Save file path.
path, err := stringutil.GetPathFromURL(media.URL)
if err != nil {
app.lo.Debug("error getting path from URL", "url", media.URL, "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error uploading file", nil, envelope.GeneralError)
}
if err := app.user.UpdateAvatar(user.ID, path); err != nil {
if err := uploadUserAvatar(r, &agent, files); err != nil {
return sendErrorEnvelope(r, err)
}
}
return r.SendEnvelope("User updated successfully.")
return r.SendEnvelope(true)
}
// handleCreateUser creates a new user.
func handleCreateUser(r *fastglue.Request) error {
// handleCreateAgent creates a new agent.
func handleCreateAgent(r *fastglue.Request) error {
var (
app = r.Context.(*App)
user = models.User{}
)
if err := r.Decode(&user, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if user.Email.String == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty `email`", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`email`"), nil, envelope.InputError)
}
if user.Roles == nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Please select at least one role", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`role`"), nil, envelope.InputError)
}
if user.FirstName == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty `first_name`", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`first_name`"), nil, envelope.InputError)
}
// Right now, only agents can be created.
@@ -225,76 +191,95 @@ func handleCreateUser(r *fastglue.Request) error {
})
if err != nil {
app.lo.Error("error rendering template", "error", err)
return r.SendEnvelope("User created successfully, but error rendering welcome email.")
return r.SendEnvelope(true)
}
if err := app.notifier.Send(notifier.Message{
RecipientEmails: []string{user.Email.String},
Subject: "Welcome",
Subject: "Welcome to Libredesk",
Content: content,
Provider: notifier.ProviderEmail,
}); err != nil {
app.lo.Error("error sending notification message", "error", err)
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, "User created successfully, but could not send welcome email.", nil))
return r.SendEnvelope(true)
}
}
return r.SendEnvelope("User created successfully.")
return r.SendEnvelope(true)
}
// handleUpdateUser updates a user.
func handleUpdateUser(r *fastglue.Request) error {
// handleUpdateAgent updates an agent.
func handleUpdateAgent(r *fastglue.Request) error {
var (
app = r.Context.(*App)
user = models.User{}
app = r.Context.(*App)
user = models.User{}
auser = r.RequestCtx.UserValue("user").(amodels.User)
ip = realip.FromRequest(r.RequestCtx)
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid user `id`.", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`id`"), nil, envelope.InputError)
}
if err := r.Decode(&user, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if user.Email.String == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty `email`", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`email`"), nil, envelope.InputError)
}
if user.Roles == nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Please select at least one role", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`role`"), nil, envelope.InputError)
}
if user.FirstName == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty `first_name`", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`first_name`"), nil, envelope.InputError)
}
// Update user.
if err = app.user.Update(id, user); err != nil {
agent, err := app.user.GetAgent(id, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
oldAvailabilityStatus := agent.AvailabilityStatus
// Update agent.
if err = app.user.UpdateAgent(id, user); err != nil {
return sendErrorEnvelope(r, err)
}
// Upsert user teams.
// Create activity log if user availability status changed.
if oldAvailabilityStatus != user.AvailabilityStatus {
if err := app.activityLog.UserAvailability(auser.ID, auser.Email, user.AvailabilityStatus, ip, user.Email.String, id); err != nil {
app.lo.Error("error creating activity log", "error", err)
}
}
// Upsert agent teams.
if err := app.team.UpsertUserTeams(id, user.Teams.Names()); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("User updated successfully.")
return r.SendEnvelope(true)
}
// handleDeleteUser soft deletes a user.
func handleDeleteUser(r *fastglue.Request) error {
// handleDeleteAgent soft deletes an agent.
func handleDeleteAgent(r *fastglue.Request) error {
var (
app = r.Context.(*App)
id, err = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid user `id`.", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "{globals.terms.user} `id`"), nil, envelope.InputError)
}
// Disallow if self-deleting.
if id == auser.ID {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("user.userCannotDeleteSelf"), nil, envelope.InputError)
}
// Soft delete user.
if err = app.user.SoftDelete(id); err != nil {
if err = app.user.SoftDeleteAgent(id); err != nil {
return sendErrorEnvelope(r, err)
}
@@ -303,54 +288,54 @@ func handleDeleteUser(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("User deleted successfully.")
return r.SendEnvelope(true)
}
// handleGetCurrentUser returns the current logged in user.
func handleGetCurrentUser(r *fastglue.Request) error {
// handleGetCurrentAgent returns the current logged in agent.
func handleGetCurrentAgent(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
u, err := app.user.GetAgent(auser.ID)
u, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(u)
}
// handleDeleteAvatar deletes a user avatar.
func handleDeleteAvatar(r *fastglue.Request) error {
// handleDeleteCurrentAgentAvatar deletes the current agent's avatar.
func handleDeleteCurrentAgentAvatar(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
// Get user
user, err := app.user.GetAgent(auser.ID)
agent, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
// Valid str?
if user.AvatarURL.String == "" {
return r.SendEnvelope("Avatar deleted successfully.")
if agent.AvatarURL.String == "" {
return r.SendEnvelope(true)
}
fileName := filepath.Base(user.AvatarURL.String)
fileName := filepath.Base(agent.AvatarURL.String)
// Delete file from the store.
if err := app.media.Delete(fileName); err != nil {
return sendErrorEnvelope(r, err)
}
if err = app.user.UpdateAvatar(user.ID, ""); err != nil {
if err = app.user.UpdateAvatar(agent.ID, ""); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("Avatar deleted successfully.")
return r.SendEnvelope(true)
}
// handleResetPassword generates a reset password token and sends an email to the user.
// handleResetPassword generates a reset password token and sends an email to the agent.
func handleResetPassword(r *fastglue.Request) error {
var (
app = r.Context.(*App)
@@ -359,20 +344,20 @@ 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, Please logout to reset password.", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("user.userAlreadyLoggedIn"), nil, envelope.InputError)
}
if email == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty `email`", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`email`"), nil, envelope.InputError)
}
user, err := app.user.GetAgentByEmail(email)
agent, err := app.user.GetAgent(0, email)
if err != nil {
// 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)
token, err := app.user.SetResetPasswordToken(agent.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -383,43 +368,107 @@ func handleResetPassword(r *fastglue.Request) error {
})
if err != nil {
app.lo.Error("error rendering template", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error rendering template", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.errorSendingPasswordResetEmail"), nil, envelope.GeneralError)
}
if err := app.notifier.Send(notifier.Message{
RecipientEmails: []string{user.Email.String},
RecipientEmails: []string{agent.Email.String},
Subject: "Reset Password",
Content: content,
Provider: notifier.ProviderEmail,
}); err != nil {
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.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.errorSendingPasswordResetEmail"), nil, envelope.GeneralError)
}
return r.SendEnvelope("Reset password email sent successfully.")
return r.SendEnvelope(true)
}
// handleSetPassword resets the password with the provided token.
func handleSetPassword(r *fastglue.Request) error {
var (
app = r.Context.(*App)
user, ok = r.RequestCtx.UserValue("user").(amodels.User)
p = r.RequestCtx.PostArgs()
password = string(p.Peek("password"))
token = string(p.Peek("token"))
app = r.Context.(*App)
agent, ok = r.RequestCtx.UserValue("user").(amodels.User)
p = r.RequestCtx.PostArgs()
password = string(p.Peek("password"))
token = string(p.Peek("token"))
)
if ok && user.ID > 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "User is already logged in", nil, envelope.InputError)
if ok && agent.ID > 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("user.userAlreadyLoggedIn"), nil, envelope.InputError)
}
if password == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty `password`", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "{globals.terms.password}"), nil, envelope.InputError)
}
if err := app.user.ResetPassword(token, password); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("Password reset successfully.")
return r.SendEnvelope(true)
}
// uploadUserAvatar uploads the user avatar.
func uploadUserAvatar(r *fastglue.Request, user *models.User, files []*multipart.FileHeader) error {
var app = r.Context.(*App)
fileHeader := files[0]
file, err := fileHeader.Open()
if err != nil {
app.lo.Error("error opening uploaded file", "error", err)
return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorReading", "name", "{globals.terms.file}"), nil)
}
defer file.Close()
// Sanitize filename.
srcFileName := stringutil.SanitizeFilename(fileHeader.Filename)
srcContentType := fileHeader.Header.Get("Content-Type")
srcFileSize := fileHeader.Size
srcExt := strings.TrimPrefix(strings.ToLower(filepath.Ext(srcFileName)), ".")
if !slices.Contains(image.Exts, srcExt) {
return envelope.NewError(envelope.InputError, app.i18n.T("globals.messages.fileTypeisNotAnImage"), nil)
}
// Check file size
if bytesToMegabytes(srcFileSize) > maxAvatarSizeMB {
app.lo.Error("error uploaded file size is larger than max allowed", "size", bytesToMegabytes(srcFileSize), "max_allowed", maxAvatarSizeMB)
return envelope.NewError(
envelope.InputError,
app.i18n.Ts("media.fileSizeTooLarge", "size", fmt.Sprintf("%dMB", maxAvatarSizeMB)),
nil,
)
}
// Reset ptr.
file.Seek(0, 0)
linkedModel := null.StringFrom(mmodels.ModelUser)
linkedID := null.IntFrom(user.ID)
disposition := null.NewString("", false)
contentID := ""
meta := []byte("{}")
media, err := app.media.UploadAndInsert(srcFileName, srcContentType, contentID, linkedModel, linkedID, file, int(srcFileSize), disposition, meta)
if err != nil {
app.lo.Error("error uploading file", "error", err)
return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorUploading", "name", "{globals.terms.file}"), nil)
}
// Delete current avatar.
if user.AvatarURL.Valid {
fileName := filepath.Base(user.AvatarURL.String)
app.media.Delete(fileName)
}
// Save file path.
path, err := stringutil.GetPathFromURL(media.URL)
if err != nil {
app.lo.Debug("error getting path from URL", "url", media.URL, "error", err)
return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorUploading", "name", "{globals.terms.file}"), nil)
}
fmt.Println("path", path)
if err := app.user.UpdateAvatar(user.ID, path); err != nil {
return sendErrorEnvelope(r, err)
}
return nil
}

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.GetAgent(auser.ID)
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -35,61 +35,49 @@ func handleCreateUserView(r *fastglue.Request) error {
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
if err := r.Decode(&view, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), err.Error(), envelope.InputError)
}
user, err := app.user.GetAgent(auser.ID)
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
if view.Name == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty view `Name`", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`Name`"), nil, envelope.InputError)
}
if string(view.Filters) == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Please provide at least one filter", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`Filters`"), nil, envelope.InputError)
}
if err := app.view.Create(view.Name, view.Filters, user.ID); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("View created successfully")
return r.SendEnvelope(true)
}
// handleGetUserView deletes a view for a user.
// handleDeleteUserView deletes a view for a user.
func handleDeleteUserView(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid view `id`.", nil, envelope.InputError)
if err != nil || id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty view `ID`", nil, envelope.InputError)
}
user, err := app.user.GetAgent(auser.ID)
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
view, err := app.view.Get(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
if view.UserID != user.ID {
return r.SendErrorEnvelope(fasthttp.StatusForbidden, "Forbidden", nil, envelope.PermissionError)
return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.PermissionError)
}
if err = app.view.Delete(id); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("View deleted successfully")
return r.SendEnvelope(true)
}
// handleUpdateUserView updates a view for a user.
@@ -101,39 +89,30 @@ func handleUpdateUserView(r *fastglue.Request) error {
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid view `id`.", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := r.Decode(&view, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), err.Error(), envelope.InputError)
}
user, err := app.user.GetAgent(auser.ID)
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
if view.Name == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty view `Name`", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
}
if string(view.Filters) == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty view `Filter`", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`filters`"), nil, envelope.InputError)
}
v, err := app.view.Get(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
if v.UserID != user.ID {
return r.SendErrorEnvelope(fasthttp.StatusForbidden, "Forbidden", nil, envelope.PermissionError)
return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.PermissionError)
}
if err = app.view.Update(id, view.Name, view.Filters); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}

View File

@@ -8,6 +8,9 @@ check_updates = true
[app.server]
address = "0.0.0.0:9000"
socket = ""
# Do NOT disable secure cookies in production environment if you don't know
# exactly what you're doing!
disable_secure_cookies = false
read_timeout = "5s"
write_timeout = "5s"
max_body_size = 500000000

View File

@@ -36,7 +36,7 @@ services:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-libredesk}
POSTGRES_DB: ${POSTGRES_DB:-libredesk}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U libredesk"]
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-libredesk} -d ${POSTGRES_DB:-libredesk}"]
interval: 10s
timeout: 5s
retries: 6
@@ -49,7 +49,8 @@ services:
container_name: libredesk_redis
restart: unless-stopped
ports:
- "6379:6379"
# Only bind on the local interface.
- "127.0.0.1:6379:6379"
networks:
- libredesk
volumes:
@@ -60,4 +61,4 @@ networks:
volumes:
postgres-data:
redis-data:
redis-data:

View File

@@ -50,15 +50,18 @@ To compile the latest unreleased version (`main` branch):
## Nginx
Libredesk using websockets for real-time updates. If you are using Nginx, you need to add the following (or similar) configuration to your Nginx configuration file.
Libredesk uses websockets for real-time updates. If you are using Nginx, you need to add the following (or similar) configuration to your Nginx configuration file.
```nginx
client_max_body_size 100M;
location / {
proxy_pass http://localhost:9000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_cache_bypass $http_upgrade;
}
```

57
docs/docs/sso.md Normal file
View File

@@ -0,0 +1,57 @@
# Setting up SSO
Libredesk supports external OpenID Connect providers (e.g., Google, Keycloak) for signing in users.
!!! note
User accounts must be created in Libredesk manually; signup is not supported.
## Generic Configuration Steps
Since each providers configuration might differ, consult your providers documentation for any additional or divergent settings.
1. Provider setup:
In your providers admin console, create a new OpenID Connect application/client. Retrieve:
- Client ID
- Client Secret
2. Libredesk configuration:
In Libredesk, navigate to Security > SSO and click New SSO and enter the following details:
- Provider URL (e.g., the URL of your OpenID provider)
- Client ID
- Client Secret
- A descriptive name for the connection
3. Redirect URL:
After saving, copy the generated Callback URL from Libredesk and add it as a valid redirect URI in your providers client settings.
## Provider Examples
#### Keycloak
1. Log in to your Keycloak Admin Console.
2. In Keycloak, navigate to Clients and click Create:
- Client ID (e.g., `libredesk-app`)
- Client Protocol: `openid-connect`
- Root URL and Web Origins: your app domain (e.g., `https://ticket.example.com`)
- Under Authentication flow, uncheck everything except the standard flow
- Click save
3. Go to the credentials tab:
- Ensure client authenticator is set to `Client Id and Secret`
- Note down the generated client secret
4. In Libredesk, go to Admin > Security > SSO and click New SSO:
- Provider URL (e.g., `https://keycloak.example.com/realms/yourrealm`)
- Name (e.g., `Keycloak`)
- Client ID
- Client secret
- Click save
5. After saving, click on the three dots and choose Edit to open the new SSO entry.
6. Copy the generated Callback URL from Libredesk.
7. Back in Keycloak, edit the client and add the Callback URL to Valid Redirect URIs:
- e.g., `https://ticket.example.com/api/v1/oidc/1/finish`

43
docs/docs/templating.md Normal file
View File

@@ -0,0 +1,43 @@
# Templating
Templating in outgoing emails allows you to personalize content by embedding dynamic expressions like `{{ .Recipient.FullName }}`. These expressions reference fields from the conversation, contact, and recipient objects.
## Outgoing Email Template Expressions
If you want to customize the look of outgoing emails, you can do so in the Admin > Templates -> Outgoing Email Templates section. This template will be used for all outgoing emails including replies to conversations, notifications, and other system-generated emails.
### Conversation Variables
| Variable | Value |
|---------------------------------|--------------------------------------------------------|
| {{ .Conversation.ReferenceNumber }} | The unique reference number of the conversation |
| {{ .Conversation.Subject }} | The subject of the conversation |
| {{ .Conversation.UUID }} | The unique identifier of the conversation |
### Contact Variables
| Variable | Value |
|------------------------------|------------------------------------|
| {{ .Contact.FirstName }} | First name of the contact/customer |
| {{ .Contact.LastName }} | Last name of the contact/customer |
| {{ .Contact.FullName }} | Full name of the contact/customer |
| {{ .Contact.Email }} | Email address of the contact/customer |
### Recipient Variables
| Variable | Value |
|--------------------------------|-----------------------------------|
| {{ .Recipient.FirstName }} | First name of the recipient |
| {{ .Recipient.LastName }} | Last name of the recipient |
| {{ .Recipient.FullName }} | Full name of the recipient |
| {{ .Recipient.Email }} | Email address of the recipient |
### Example outgoing email template
```html
Dear {{ .Recipient.FirstName }}
{{ template "content" . }}
Best regards,
```
Here, the `{{ template "content" . }}` serves as a placeholder for the body of the outgoing email. It will be replaced with the actual email content at the time of sending.
Similarly, the `{{ .Recipient.FirstName }}` expression will dynamically insert the recipient's first name when the email is sent.

View File

@@ -0,0 +1,3 @@
# Translations / Internationalization
You can help translate libreDesk into different languages by contributing here: [LibreDesk Translation Project](https://crowdin.com/project/libredesk)

View File

@@ -1,6 +1,6 @@
# Upgrade
!!! Warning
!!! warning "Warning"
Always take a backup of the Postgres database before upgrading Libredesk.
## Binary
@@ -15,4 +15,4 @@
docker compose down app
docker compose pull
docker compose up app -d
```
```

View File

@@ -31,4 +31,8 @@ nav:
- Getting Started:
- Installation: installation.md
- Upgrade: upgrade.md
- Developer Setup: developer-setup.md
- Templating: templating.md
- SSO: sso.md
- Contributors:
- Developer setup: developer-setup.md
- Translations: translations.md

View File

@@ -8,7 +8,6 @@
"baseColor": "gray",
"cssVariables": true
},
"framework": "vite",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"

View File

@@ -3,7 +3,7 @@ import { defineConfig } from 'cypress'
export default defineConfig({
e2e: {
specPattern: 'cypress/e2e/**/*.{cy,spec}.{js,jsx,ts,tsx}',
baseUrl: 'http://localhost:4173'
baseUrl: 'http://localhost:9000'
},
component: {
specPattern: 'src/**/__tests__/*.{cy,spec}.{js,ts,jsx,tsx}',

View File

@@ -1,8 +0,0 @@
// https://on.cypress.io/api
describe('My First Test', () => {
it('visits the app root url', () => {
cy.visit('/')
cy.contains('h1', 'You did it!')
})
})

View File

@@ -0,0 +1,140 @@
// cypress/e2e/login.cy.js
describe('Login Component', () => {
beforeEach(() => {
// Visit the login page
cy.visit('/')
// Mock the API response for OIDC providers
cy.intercept('GET', '**/api/v1/oidc/enabled', {
statusCode: 200,
body: {
data: [
{
id: 1,
name: 'Google',
logo_url: 'https://example.com/google-logo.png',
disabled: false
}
]
}
}).as('getOIDCProviders')
})
it('should display login form', () => {
cy.contains('h3', 'Libredesk').should('be.visible')
cy.contains('p', 'Sign in to your account').should('be.visible')
cy.get('#email').should('be.visible')
cy.get('#password').should('be.visible')
cy.contains('a', 'Forgot password?').should('be.visible')
cy.contains('button', 'Sign in').should('be.visible')
})
it('should display OIDC providers when loaded', () => {
cy.wait('@getOIDCProviders')
cy.contains('button', 'Google').should('be.visible')
cy.contains('div', 'Or continue with').should('be.visible')
})
it('should show error for invalid login attempt', () => {
// Mock failed login API call
cy.intercept('POST', '**/api/v1/login', {
statusCode: 401,
body: {
message: 'Invalid credentials'
}
}).as('loginFailure')
// Enter System username and wrong password
cy.get('#email').type('System')
cy.get('#password').type('WrongPassword')
// Submit form
cy.contains('button', 'Sign in').click()
// Wait for API call
cy.wait('@loginFailure')
// Verify error message appears
cy.contains('Invalid credentials').should('be.visible')
})
it('should login successfully with correct credentials', () => {
// Mock successful login API call
cy.intercept('POST', '**/api/v1/login', {
statusCode: 200,
body: {
data: {
id: 1,
email: 'System',
name: 'System User'
}
}
}).as('loginSuccess')
// Enter System username and correct password
cy.get('#email').type('System')
cy.get('#password').type('StrongPass!123')
// Submit form
cy.contains('button', 'Sign in').click()
// Wait for API call
cy.wait('@loginSuccess')
// Verify redirection to inboxes page
cy.url().should('include', '/inboxes/assigned')
})
it('should validate email format', () => {
// Enter invalid email and a password
cy.get('#email').type('invalid-email')
cy.get('#password').type('password')
// Submit form
cy.contains('button', 'Sign in').click()
// Check for validation error (matching the error message with a trailing period)
cy.contains('Invalid email address').should('be.visible')
})
it('should validate empty password', () => {
// Enter email but no password
cy.get('#email').type('valid@example.com')
// Submit form
cy.contains('button', 'Sign in').click()
// Check for validation error (matching the error message with a trailing period)
cy.contains('Password cannot be empty').should('be.visible')
})
it('should show loading state during login', () => {
// Mock slow API response
cy.intercept('POST', '**/api/v1/login', {
statusCode: 200,
body: {
data: {
id: 1,
email: 'System',
name: 'System User'
}
},
delay: 1000
}).as('slowLogin')
// Enter credentials
cy.get('#email').type('System')
cy.get('#password').type('StrongPass!123')
// Submit form
cy.contains('button', 'Sign in').click()
// Check if loading state is shown
cy.contains('Logging in...').should('be.visible')
cy.get('svg.animate-spin').should('be.visible')
// Wait for API call to finish
cy.wait('@slowLogin')
})
})

View File

@@ -6,8 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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&display=swap"
<link href="https://fonts.googleapis.com/css2?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.3.0",
"version": "0.6.0-alpha",
"private": true,
"type": "module",
"scripts": {
@@ -8,6 +8,7 @@
"build": "vite build",
"preview": "vite preview",
"test:e2e": "start-server-and-test preview http://localhost:4173 'cypress run --e2e'",
"test:e2e:ci": "cypress run --e2e --headless",
"test:e2e:dev": "start-server-and-test 'vite dev --port 4173' http://localhost:4173 'cypress open --e2e'",
"test:unit": "cypress run --component",
"test:unit:dev": "cypress open --component",
@@ -32,9 +33,9 @@
"@tiptap/vue-3": "^2.4.0",
"@unovis/ts": "^1.4.4",
"@unovis/vue": "^1.4.4",
"@vee-validate/zod": "^4.13.2",
"@vee-validate/zod": "^4.15.0",
"@vueuse/core": "^12.4.0",
"axios": "^1.7.9",
"axios": "^1.8.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"codeflask": "^1.4.1",
@@ -43,9 +44,10 @@
"mitt": "^3.0.1",
"pinia": "^2.1.7",
"qs": "^6.12.1",
"radix-vue": "latest",
"radix-vue": "^1.9.17",
"reka-ui": "^2.2.0",
"tailwind-merge": "^2.3.0",
"vee-validate": "^4.13.2",
"vee-validate": "^4.15.0",
"vue": "^3.4.37",
"vue-dompurify-html": "^5.2.0",
"vue-i18n": "9",
@@ -55,7 +57,7 @@
"vue-sonner": "^1.3.0",
"vue3-emoji-picker": "^1.1.8",
"vuedraggable": "^4.1.0",
"zod": "^3.23.8"
"zod": "^3.24.1"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.3.3",
@@ -70,9 +72,9 @@
"prettier": "^3.0.3",
"sass": "^1.70.0",
"start-server-and-test": "^2.0.3",
"tailwindcss": "latest",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"vite": "^5.4.9"
"vite": "^5.4.18"
},
"packageManager": "pnpm@9.15.3+sha512.1f79bc245a66eb0b07c5d4d83131240774642caaa86ef7d0434ab47c0d16f66b04e21e0c086eb61e62c77efc4d7f7ec071afad3796af64892fae66509173893a"
}

427
frontend/pnpm-lock.yaml generated
View File

@@ -60,14 +60,14 @@ importers:
specifier: ^1.4.4
version: 1.5.0(@unovis/ts@1.5.0)(vue@3.5.13(typescript@5.7.3))
'@vee-validate/zod':
specifier: ^4.13.2
specifier: ^4.15.0
version: 4.15.0(vue@3.5.13(typescript@5.7.3))(zod@3.24.1)
'@vueuse/core':
specifier: ^12.4.0
version: 12.4.0(typescript@5.7.3)
axios:
specifier: ^1.7.9
version: 1.7.9(debug@4.4.0)
specifier: ^1.8.2
version: 1.8.2(debug@4.4.0)
class-variance-authority:
specifier: ^0.7.0
version: 0.7.1
@@ -93,13 +93,16 @@ importers:
specifier: ^6.12.1
version: 6.13.1
radix-vue:
specifier: latest
version: 1.9.12(vue@3.5.13(typescript@5.7.3))
specifier: ^1.9.17
version: 1.9.17(vue@3.5.13(typescript@5.7.3))
reka-ui:
specifier: ^2.2.0
version: 2.2.0(typescript@5.7.3)(vue@3.5.13(typescript@5.7.3))
tailwind-merge:
specifier: ^2.3.0
version: 2.6.0
vee-validate:
specifier: ^4.13.2
specifier: ^4.15.0
version: 4.15.0(vue@3.5.13(typescript@5.7.3))
vue:
specifier: ^3.4.37
@@ -109,7 +112,7 @@ importers:
version: 5.2.0(vue@3.5.13(typescript@5.7.3))
vue-i18n:
specifier: '9'
version: 9.14.2(vue@3.5.13(typescript@5.7.3))
version: 9.14.3(vue@3.5.13(typescript@5.7.3))
vue-letter:
specifier: ^0.2.0
version: 0.2.0
@@ -129,7 +132,7 @@ importers:
specifier: ^4.1.0
version: 4.1.0(vue@3.5.13(typescript@5.7.3))
zod:
specifier: ^3.23.8
specifier: ^3.24.1
version: 3.24.1
devDependencies:
'@rushstack/eslint-patch':
@@ -137,7 +140,7 @@ importers:
version: 1.10.5
'@vitejs/plugin-vue':
specifier: ^5.0.3
version: 5.2.1(vite@5.4.11(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0))(vue@3.5.13(typescript@5.7.3))
version: 5.2.1(vite@5.4.18(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0))(vue@3.5.13(typescript@5.7.3))
'@vue/eslint-config-prettier':
specifier: ^8.0.0
version: 8.0.0(eslint@8.57.1)(prettier@3.4.2)
@@ -169,14 +172,14 @@ importers:
specifier: ^2.0.3
version: 2.0.9
tailwindcss:
specifier: latest
specifier: ^3.4.17
version: 3.4.17
tailwindcss-animate:
specifier: ^1.0.7
version: 1.0.7(tailwindcss@3.4.17)
vite:
specifier: ^5.4.9
version: 5.4.11(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)
specifier: ^5.4.18
version: 5.4.18(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)
packages:
@@ -465,16 +468,16 @@ packages:
'@internationalized/number@3.6.0':
resolution: {integrity: sha512-PtrRcJVy7nw++wn4W2OuePQQfTqDzfusSuY1QTtui4wa7r+rGVtR75pO8CyKvHvzyQYi3Q1uO5sY0AsB4e65Bw==}
'@intlify/core-base@9.14.2':
resolution: {integrity: sha512-DZyQ4Hk22sC81MP4qiCDuU+LdaYW91A6lCjq8AWPvY3+mGMzhGDfOCzvyR6YBQxtlPjFqMoFk9ylnNYRAQwXtQ==}
'@intlify/core-base@9.14.3':
resolution: {integrity: sha512-nbJ7pKTlXFnaXPblyfiH6awAx1C0PWNNuqXAR74yRwgi5A/Re/8/5fErLY0pv4R8+EHj3ZaThMHdnuC/5OBa6g==}
engines: {node: '>= 16'}
'@intlify/message-compiler@9.14.2':
resolution: {integrity: sha512-YsKKuV4Qv4wrLNsvgWbTf0E40uRv+Qiw1BeLQ0LAxifQuhiMe+hfTIzOMdWj/ZpnTDj4RSZtkXjJM7JDiiB5LQ==}
'@intlify/message-compiler@9.14.3':
resolution: {integrity: sha512-ANwC226BQdd+MpJ36rOYkChSESfPwu3Ss2Faw0RHTOknYLoHTX6V6e/JjIKVDMbzs0/H/df/rO6yU0SPiWHqNg==}
engines: {node: '>= 16'}
'@intlify/shared@9.14.2':
resolution: {integrity: sha512-uRAHAxYPeF+G5DBIboKpPgC/Waecd4Jz8ihtkpJQD5ycb5PwXp0k/+hBGl5dAjwF7w+l74kz/PKA8r8OK//RUw==}
'@intlify/shared@9.14.3':
resolution: {integrity: sha512-hJXz9LA5VG7qNE00t50bdzDv8Z4q9fpcL81wj4y4duKavrv0KM8YNLTwXNEFINHjTsfrG9TXvPuEjVaAvZ7yWg==}
engines: {node: '>= 16'}
'@isaacs/cliui@8.0.2':
@@ -642,98 +645,103 @@ packages:
'@remirror/core-constants@3.0.0':
resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==}
'@rollup/rollup-android-arm-eabi@4.30.1':
resolution: {integrity: sha512-pSWY+EVt3rJ9fQ3IqlrEUtXh3cGqGtPDH1FQlNZehO2yYxCHEX1SPsz1M//NXwYfbTlcKr9WObLnJX9FsS9K1Q==}
'@rollup/rollup-android-arm-eabi@4.40.1':
resolution: {integrity: sha512-kxz0YeeCrRUHz3zyqvd7n+TVRlNyTifBsmnmNPtk3hQURUyG9eAB+usz6DAwagMusjx/zb3AjvDUvhFGDAexGw==}
cpu: [arm]
os: [android]
'@rollup/rollup-android-arm64@4.30.1':
resolution: {integrity: sha512-/NA2qXxE3D/BRjOJM8wQblmArQq1YoBVJjrjoTSBS09jgUisq7bqxNHJ8kjCHeV21W/9WDGwJEWSN0KQ2mtD/w==}
'@rollup/rollup-android-arm64@4.40.1':
resolution: {integrity: sha512-PPkxTOisoNC6TpnDKatjKkjRMsdaWIhyuMkA4UsBXT9WEZY4uHezBTjs6Vl4PbqQQeu6oION1w2voYZv9yquCw==}
cpu: [arm64]
os: [android]
'@rollup/rollup-darwin-arm64@4.30.1':
resolution: {integrity: sha512-r7FQIXD7gB0WJ5mokTUgUWPl0eYIH0wnxqeSAhuIwvnnpjdVB8cRRClyKLQr7lgzjctkbp5KmswWszlwYln03Q==}
'@rollup/rollup-darwin-arm64@4.40.1':
resolution: {integrity: sha512-VWXGISWFY18v/0JyNUy4A46KCFCb9NVsH+1100XP31lud+TzlezBbz24CYzbnA4x6w4hx+NYCXDfnvDVO6lcAA==}
cpu: [arm64]
os: [darwin]
'@rollup/rollup-darwin-x64@4.30.1':
resolution: {integrity: sha512-x78BavIwSH6sqfP2xeI1hd1GpHL8J4W2BXcVM/5KYKoAD3nNsfitQhvWSw+TFtQTLZ9OmlF+FEInEHyubut2OA==}
'@rollup/rollup-darwin-x64@4.40.1':
resolution: {integrity: sha512-nIwkXafAI1/QCS7pxSpv/ZtFW6TXcNUEHAIA9EIyw5OzxJZQ1YDrX+CL6JAIQgZ33CInl1R6mHet9Y/UZTg2Bw==}
cpu: [x64]
os: [darwin]
'@rollup/rollup-freebsd-arm64@4.30.1':
resolution: {integrity: sha512-HYTlUAjbO1z8ywxsDFWADfTRfTIIy/oUlfIDmlHYmjUP2QRDTzBuWXc9O4CXM+bo9qfiCclmHk1x4ogBjOUpUQ==}
'@rollup/rollup-freebsd-arm64@4.40.1':
resolution: {integrity: sha512-BdrLJ2mHTrIYdaS2I99mriyJfGGenSaP+UwGi1kB9BLOCu9SR8ZpbkmmalKIALnRw24kM7qCN0IOm6L0S44iWw==}
cpu: [arm64]
os: [freebsd]
'@rollup/rollup-freebsd-x64@4.30.1':
resolution: {integrity: sha512-1MEdGqogQLccphhX5myCJqeGNYTNcmTyaic9S7CG3JhwuIByJ7J05vGbZxsizQthP1xpVx7kd3o31eOogfEirw==}
'@rollup/rollup-freebsd-x64@4.40.1':
resolution: {integrity: sha512-VXeo/puqvCG8JBPNZXZf5Dqq7BzElNJzHRRw3vjBE27WujdzuOPecDPc/+1DcdcTptNBep3861jNq0mYkT8Z6Q==}
cpu: [x64]
os: [freebsd]
'@rollup/rollup-linux-arm-gnueabihf@4.30.1':
resolution: {integrity: sha512-PaMRNBSqCx7K3Wc9QZkFx5+CX27WFpAMxJNiYGAXfmMIKC7jstlr32UhTgK6T07OtqR+wYlWm9IxzennjnvdJg==}
'@rollup/rollup-linux-arm-gnueabihf@4.40.1':
resolution: {integrity: sha512-ehSKrewwsESPt1TgSE/na9nIhWCosfGSFqv7vwEtjyAqZcvbGIg4JAcV7ZEh2tfj/IlfBeZjgOXm35iOOjadcg==}
cpu: [arm]
os: [linux]
'@rollup/rollup-linux-arm-musleabihf@4.30.1':
resolution: {integrity: sha512-B8Rcyj9AV7ZlEFqvB5BubG5iO6ANDsRKlhIxySXcF1axXYUyqwBok+XZPgIYGBgs7LDXfWfifxhw0Ik57T0Yug==}
'@rollup/rollup-linux-arm-musleabihf@4.40.1':
resolution: {integrity: sha512-m39iO/aaurh5FVIu/F4/Zsl8xppd76S4qoID8E+dSRQvTyZTOI2gVk3T4oqzfq1PtcvOfAVlwLMK3KRQMaR8lg==}
cpu: [arm]
os: [linux]
'@rollup/rollup-linux-arm64-gnu@4.30.1':
resolution: {integrity: sha512-hqVyueGxAj3cBKrAI4aFHLV+h0Lv5VgWZs9CUGqr1z0fZtlADVV1YPOij6AhcK5An33EXaxnDLmJdQikcn5NEw==}
'@rollup/rollup-linux-arm64-gnu@4.40.1':
resolution: {integrity: sha512-Y+GHnGaku4aVLSgrT0uWe2o2Rq8te9hi+MwqGF9r9ORgXhmHK5Q71N757u0F8yU1OIwUIFy6YiJtKjtyktk5hg==}
cpu: [arm64]
os: [linux]
'@rollup/rollup-linux-arm64-musl@4.30.1':
resolution: {integrity: sha512-i4Ab2vnvS1AE1PyOIGp2kXni69gU2DAUVt6FSXeIqUCPIR3ZlheMW3oP2JkukDfu3PsexYRbOiJrY+yVNSk9oA==}
'@rollup/rollup-linux-arm64-musl@4.40.1':
resolution: {integrity: sha512-jEwjn3jCA+tQGswK3aEWcD09/7M5wGwc6+flhva7dsQNRZZTe30vkalgIzV4tjkopsTS9Jd7Y1Bsj6a4lzz8gQ==}
cpu: [arm64]
os: [linux]
'@rollup/rollup-linux-loongarch64-gnu@4.30.1':
resolution: {integrity: sha512-fARcF5g296snX0oLGkVxPmysetwUk2zmHcca+e9ObOovBR++9ZPOhqFUM61UUZ2EYpXVPN1redgqVoBB34nTpQ==}
'@rollup/rollup-linux-loongarch64-gnu@4.40.1':
resolution: {integrity: sha512-ySyWikVhNzv+BV/IDCsrraOAZ3UaC8SZB67FZlqVwXwnFhPihOso9rPOxzZbjp81suB1O2Topw+6Ug3JNegejQ==}
cpu: [loong64]
os: [linux]
'@rollup/rollup-linux-powerpc64le-gnu@4.30.1':
resolution: {integrity: sha512-GLrZraoO3wVT4uFXh67ElpwQY0DIygxdv0BNW9Hkm3X34wu+BkqrDrkcsIapAY+N2ATEbvak0XQ9gxZtCIA5Rw==}
'@rollup/rollup-linux-powerpc64le-gnu@4.40.1':
resolution: {integrity: sha512-BvvA64QxZlh7WZWqDPPdt0GH4bznuL6uOO1pmgPnnv86rpUpc8ZxgZwcEgXvo02GRIZX1hQ0j0pAnhwkhwPqWg==}
cpu: [ppc64]
os: [linux]
'@rollup/rollup-linux-riscv64-gnu@4.30.1':
resolution: {integrity: sha512-0WKLaAUUHKBtll0wvOmh6yh3S0wSU9+yas923JIChfxOaaBarmb/lBKPF0w/+jTVozFnOXJeRGZ8NvOxvk/jcw==}
'@rollup/rollup-linux-riscv64-gnu@4.40.1':
resolution: {integrity: sha512-EQSP+8+1VuSulm9RKSMKitTav89fKbHymTf25n5+Yr6gAPZxYWpj3DzAsQqoaHAk9YX2lwEyAf9S4W8F4l3VBQ==}
cpu: [riscv64]
os: [linux]
'@rollup/rollup-linux-s390x-gnu@4.30.1':
resolution: {integrity: sha512-GWFs97Ruxo5Bt+cvVTQkOJ6TIx0xJDD/bMAOXWJg8TCSTEK8RnFeOeiFTxKniTc4vMIaWvCplMAFBt9miGxgkA==}
'@rollup/rollup-linux-riscv64-musl@4.40.1':
resolution: {integrity: sha512-n/vQ4xRZXKuIpqukkMXZt9RWdl+2zgGNx7Uda8NtmLJ06NL8jiHxUawbwC+hdSq1rrw/9CghCpEONor+l1e2gA==}
cpu: [riscv64]
os: [linux]
'@rollup/rollup-linux-s390x-gnu@4.40.1':
resolution: {integrity: sha512-h8d28xzYb98fMQKUz0w2fMc1XuGzLLjdyxVIbhbil4ELfk5/orZlSTpF/xdI9C8K0I8lCkq+1En2RJsawZekkg==}
cpu: [s390x]
os: [linux]
'@rollup/rollup-linux-x64-gnu@4.30.1':
resolution: {integrity: sha512-UtgGb7QGgXDIO+tqqJ5oZRGHsDLO8SlpE4MhqpY9Llpzi5rJMvrK6ZGhsRCST2abZdBqIBeXW6WPD5fGK5SDwg==}
'@rollup/rollup-linux-x64-gnu@4.40.1':
resolution: {integrity: sha512-XiK5z70PEFEFqcNj3/zRSz/qX4bp4QIraTy9QjwJAb/Z8GM7kVUsD0Uk8maIPeTyPCP03ChdI+VVmJriKYbRHQ==}
cpu: [x64]
os: [linux]
'@rollup/rollup-linux-x64-musl@4.30.1':
resolution: {integrity: sha512-V9U8Ey2UqmQsBT+xTOeMzPzwDzyXmnAoO4edZhL7INkwQcaW1Ckv3WJX3qrrp/VHaDkEWIBWhRwP47r8cdrOow==}
'@rollup/rollup-linux-x64-musl@4.40.1':
resolution: {integrity: sha512-2BRORitq5rQ4Da9blVovzNCMaUlyKrzMSvkVR0D4qPuOy/+pMCrh1d7o01RATwVy+6Fa1WBw+da7QPeLWU/1mQ==}
cpu: [x64]
os: [linux]
'@rollup/rollup-win32-arm64-msvc@4.30.1':
resolution: {integrity: sha512-WabtHWiPaFF47W3PkHnjbmWawnX/aE57K47ZDT1BXTS5GgrBUEpvOzq0FI0V/UYzQJgdb8XlhVNH8/fwV8xDjw==}
'@rollup/rollup-win32-arm64-msvc@4.40.1':
resolution: {integrity: sha512-b2bcNm9Kbde03H+q+Jjw9tSfhYkzrDUf2d5MAd1bOJuVplXvFhWz7tRtWvD8/ORZi7qSCy0idW6tf2HgxSXQSg==}
cpu: [arm64]
os: [win32]
'@rollup/rollup-win32-ia32-msvc@4.30.1':
resolution: {integrity: sha512-pxHAU+Zv39hLUTdQQHUVHf4P+0C47y/ZloorHpzs2SXMRqeAWmGghzAhfOlzFHHwjvgokdFAhC4V+6kC1lRRfw==}
'@rollup/rollup-win32-ia32-msvc@4.40.1':
resolution: {integrity: sha512-DfcogW8N7Zg7llVEfpqWMZcaErKfsj9VvmfSyRjCyo4BI3wPEfrzTtJkZG6gKP/Z92wFm6rz2aDO7/JfiR/whA==}
cpu: [ia32]
os: [win32]
'@rollup/rollup-win32-x64-msvc@4.30.1':
resolution: {integrity: sha512-D6qjsXGcvhTjv0kI4fU8tUuBDF/Ueee4SVX79VfNDXZa64TfCW1Slkb6Z7O1p7vflqZjcmOVdZlqf8gvJxc6og==}
'@rollup/rollup-win32-x64-msvc@4.40.1':
resolution: {integrity: sha512-ECyOuDeH3C1I8jH2MK1RtBJW+YPMvSfT0a5NN0nHfQYnDSJ6tUiZH3gzwVP5/Kfh/+Tt7tpWVF9LXNTnhTJ3kA==}
cpu: [x64]
os: [win32]
@@ -764,6 +772,9 @@ packages:
'@tanstack/virtual-core@3.11.2':
resolution: {integrity: sha512-vTtpNt7mKCiZ1pwU9hfKPhpdVO2sVzFQsxoVBGtOSHxlrRRzYr8iQ2TlwbAcRYCcEiZ9ECAM8kBzH0v2+VzfKw==}
'@tanstack/virtual-core@3.13.6':
resolution: {integrity: sha512-cnQUeWnhNP8tJ4WsGcYiX24Gjkc9ALstLbHcBj1t3E7EimN6n6kHH+DPV4PpDnuw00NApQp+ViojMj1GRdwYQg==}
'@tanstack/vue-table@8.20.5':
resolution: {integrity: sha512-2xixT3BEgSDw+jOSqPt6ylO/eutDI107t2WdFMVYIZZ45UmTHLySqNriNs0+dMaKR56K5z3t+97P6VuVnI2L+Q==}
engines: {node: '>=12'}
@@ -775,6 +786,11 @@ packages:
peerDependencies:
vue: ^2.7.0 || ^3.0.0
'@tanstack/vue-virtual@3.13.6':
resolution: {integrity: sha512-GYdZ3SJBQPzgxhuCE2fvpiH46qzHiVx5XzBSdtESgiqh4poj8UgckjGWYEhxaBbcVt1oLzh1m3Ql4TyH32TOzQ==}
peerDependencies:
vue: ^2.7.0 || ^3.0.0
'@tiptap/core@2.11.2':
resolution: {integrity: sha512-Z437c/sQg31yrRVgLJVkQuih+7Og5tjRx6FE/zE47QgEayqQ9yXH0LrTAbPiY6IfY1X+f2A0h3e5Y/WGD6rC3Q==}
peerDependencies:
@@ -1051,8 +1067,8 @@ packages:
'@types/dagre@0.7.52':
resolution: {integrity: sha512-XKJdy+OClLk3hketHi9Qg6gTfe1F3y+UFnHxKA2rn9Dw+oXa4Gb378Ztz9HlMgZKSxpPmn4BNVh9wgkpvrK1uw==}
'@types/estree@1.0.6':
resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==}
'@types/estree@1.0.7':
resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==}
'@types/geojson@7946.0.15':
resolution: {integrity: sha512-9oSxFzDCT2Rj6DfcHF8G++jxBKS7mBqXl5xrRW+Kbvjry6Uduya2iiwqHPhVXpasAVMBYKkEPGgKhd3+/HZ6xA==}
@@ -1123,6 +1139,9 @@ packages:
'@types/web-bluetooth@0.0.20':
resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
'@types/web-bluetooth@0.0.21':
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
'@types/yauzl@2.10.3':
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
@@ -1209,18 +1228,27 @@ packages:
'@vueuse/core@12.4.0':
resolution: {integrity: sha512-XnjQYcJwCsyXyIafyA6SvyN/OBtfPnjvJmbxNxQjCcyWD198urwm5TYvIUUyAxEAN0K7HJggOgT15cOlWFyLeA==}
'@vueuse/core@12.8.2':
resolution: {integrity: sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==}
'@vueuse/metadata@10.11.1':
resolution: {integrity: sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==}
'@vueuse/metadata@12.4.0':
resolution: {integrity: sha512-AhPuHs/qtYrKHUlEoNO6zCXufu8OgbR8S/n2oMw1OQuBQJ3+HOLQ+EpvXs+feOlZMa0p8QVvDWNlmcJJY8rW2g==}
'@vueuse/metadata@12.8.2':
resolution: {integrity: sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==}
'@vueuse/shared@10.11.1':
resolution: {integrity: sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==}
'@vueuse/shared@12.4.0':
resolution: {integrity: sha512-9yLgbHVIF12OSCojnjTIoZL1+UA10+O4E1aD6Hpfo/DKVm5o3SZIwz6CupqGy3+IcKI8d6Jnl26EQj/YucnW0Q==}
'@vueuse/shared@12.8.2':
resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==}
'@withtypes/mime@0.1.2':
resolution: {integrity: sha512-PB9BfZGzwblUONJY0LiOwsHCA6uV3DIPj/w9ReekdHxPOl0VdUFgI5s4avKycuuq9Gf5Nz2ZPA2O36GAUzlMPA==}
@@ -1324,8 +1352,8 @@ packages:
aws4@1.13.2:
resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==}
axios@1.7.9:
resolution: {integrity: sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==}
axios@1.8.2:
resolution: {integrity: sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==}
babel-plugin-macros@3.1.0:
resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==}
@@ -1385,6 +1413,10 @@ packages:
resolution: {integrity: sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==}
engines: {node: '>= 0.4'}
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
call-bound@1.0.3:
resolution: {integrity: sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==}
engines: {node: '>= 0.4'}
@@ -1794,6 +1826,14 @@ packages:
resolution: {integrity: sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==}
engines: {node: '>= 0.4'}
es-object-atoms@1.1.1:
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
engines: {node: '>= 0.4'}
es-set-tostringtag@2.1.0:
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
engines: {node: '>= 0.4'}
esbuild@0.21.5:
resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
engines: {node: '>=12'}
@@ -1966,15 +2006,15 @@ packages:
debug:
optional: true
foreground-child@3.3.0:
resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==}
foreground-child@3.3.1:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
forever-agent@0.6.1:
resolution: {integrity: sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==}
form-data@4.0.1:
resolution: {integrity: sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==}
form-data@4.0.2:
resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==}
engines: {node: '>= 6'}
fraction.js@4.3.7:
@@ -2009,6 +2049,10 @@ packages:
resolution: {integrity: sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA==}
engines: {node: '>= 0.4'}
get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
get-proto@1.0.1:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
@@ -2080,6 +2124,10 @@ packages:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
engines: {node: '>= 0.4'}
has-tostringtag@1.0.2:
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
engines: {node: '>= 0.4'}
hasown@2.0.2:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
@@ -2459,6 +2507,9 @@ packages:
resolution: {integrity: sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==}
engines: {node: '>= 0.4'}
ohash@2.0.11:
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
@@ -2561,8 +2612,8 @@ packages:
typescript:
optional: true
pirates@4.0.6:
resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==}
pirates@4.0.7:
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
engines: {node: '>= 6'}
postcss-import@15.1.0:
@@ -2731,8 +2782,8 @@ packages:
quickselect@2.0.0:
resolution: {integrity: sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==}
radix-vue@1.9.12:
resolution: {integrity: sha512-zkr66Jqxbej4+oR6O/pZRzyM/VZi66ndbyIBZQjJKAXa1lIoYReZJse6W1EEDZKXknD7rXhpS+jM9Sr23lIqfg==}
radix-vue@1.9.17:
resolution: {integrity: sha512-mVCu7I2vXt1L2IUYHTt0sZMz7s1K2ZtqKeTIxG3yC5mMFfLBG4FtE1FDeRMpDd+Hhg/ybi9+iXmAP1ISREndoQ==}
peerDependencies:
vue: '>= 3.2.0'
@@ -2750,6 +2801,11 @@ packages:
regenerator-runtime@0.14.1:
resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==}
reka-ui@2.2.0:
resolution: {integrity: sha512-eeRrLI4LwJ6dkdwks6KFNKGs0+beqZlHO3JMHen7THDTh+yJ5Z0KNwONmOhhV/0hZC2uJCEExgG60QPzGstkQg==}
peerDependencies:
vue: '>= 3.2.0'
request-progress@3.0.0:
resolution: {integrity: sha512-MnWzEHHaxHO2iWiQuHrUPBi/1WeBf5PkxQqNyNvLl9VAYSdXkP8tQ3pBSeCPD+yw0v0Aq1zosWLz0BdeXpWwZg==}
@@ -2784,8 +2840,8 @@ packages:
robust-predicates@3.0.2:
resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==}
rollup@4.30.1:
resolution: {integrity: sha512-mlJ4glW020fPuLi7DkM/lN97mYEZGWeqBnrljzN0gs7GLctqX3lNWxKQ7Gl712UAX+6fog/L3jh4gb7R6aVi3w==}
rollup@4.40.1:
resolution: {integrity: sha512-C5VvvgCCyfyotVITIAv+4efVytl5F7wt+/I2i9q9GZcEXW9BP52YYOXC58igUi+LFZVHukErIIqQSWwv/M3WRw==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
@@ -3108,8 +3164,8 @@ packages:
resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==}
engines: {'0': node >=0.6.0}
vite@5.4.11:
resolution: {integrity: sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==}
vite@5.4.18:
resolution: {integrity: sha512-1oDcnEp3lVyHCuQ2YFelM4Alm2o91xNoMncRm1U7S+JdYfYOvbiGZ3/CxGttrOu2M/KcGz7cRC2DoNUA6urmMA==}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
peerDependencies:
@@ -3164,8 +3220,8 @@ packages:
peerDependencies:
eslint: '>=6.0.0'
vue-i18n@9.14.2:
resolution: {integrity: sha512-JK9Pm80OqssGJU2Y6F7DcM8RFHqVG4WkuCqOZTVsXkEzZME7ABejAUqUdA931zEBedc4thBgSUWxeQh4uocJAQ==}
vue-i18n@9.14.3:
resolution: {integrity: sha512-C+E0KE8ihKjdYCQx8oUkXX+8tBItrYNMnGJuzEPevBARQFUN2tKez6ZVOvBrWH0+KT5wEk3vOWjNk7ygb2u9ig==}
engines: {node: '>= 16'}
peerDependencies:
vue: ^3.0.0
@@ -3247,8 +3303,8 @@ packages:
resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==}
engines: {node: '>= 6'}
yaml@2.7.0:
resolution: {integrity: sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==}
yaml@2.7.1:
resolution: {integrity: sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==}
engines: {node: '>= 14'}
hasBin: true
@@ -3337,7 +3393,7 @@ snapshots:
combined-stream: 1.0.8
extend: 3.0.2
forever-agent: 0.6.1
form-data: 4.0.1
form-data: 4.0.2
http-signature: 1.4.0
is-typedarray: 1.0.0
isstream: 0.1.2
@@ -3551,17 +3607,17 @@ snapshots:
dependencies:
'@swc/helpers': 0.5.15
'@intlify/core-base@9.14.2':
'@intlify/core-base@9.14.3':
dependencies:
'@intlify/message-compiler': 9.14.2
'@intlify/shared': 9.14.2
'@intlify/message-compiler': 9.14.3
'@intlify/shared': 9.14.3
'@intlify/message-compiler@9.14.2':
'@intlify/message-compiler@9.14.3':
dependencies:
'@intlify/shared': 9.14.2
'@intlify/shared': 9.14.3
source-map-js: 1.2.1
'@intlify/shared@9.14.2': {}
'@intlify/shared@9.14.3': {}
'@isaacs/cliui@8.0.2':
dependencies:
@@ -3698,61 +3754,64 @@ snapshots:
'@remirror/core-constants@3.0.0': {}
'@rollup/rollup-android-arm-eabi@4.30.1':
'@rollup/rollup-android-arm-eabi@4.40.1':
optional: true
'@rollup/rollup-android-arm64@4.30.1':
'@rollup/rollup-android-arm64@4.40.1':
optional: true
'@rollup/rollup-darwin-arm64@4.30.1':
'@rollup/rollup-darwin-arm64@4.40.1':
optional: true
'@rollup/rollup-darwin-x64@4.30.1':
'@rollup/rollup-darwin-x64@4.40.1':
optional: true
'@rollup/rollup-freebsd-arm64@4.30.1':
'@rollup/rollup-freebsd-arm64@4.40.1':
optional: true
'@rollup/rollup-freebsd-x64@4.30.1':
'@rollup/rollup-freebsd-x64@4.40.1':
optional: true
'@rollup/rollup-linux-arm-gnueabihf@4.30.1':
'@rollup/rollup-linux-arm-gnueabihf@4.40.1':
optional: true
'@rollup/rollup-linux-arm-musleabihf@4.30.1':
'@rollup/rollup-linux-arm-musleabihf@4.40.1':
optional: true
'@rollup/rollup-linux-arm64-gnu@4.30.1':
'@rollup/rollup-linux-arm64-gnu@4.40.1':
optional: true
'@rollup/rollup-linux-arm64-musl@4.30.1':
'@rollup/rollup-linux-arm64-musl@4.40.1':
optional: true
'@rollup/rollup-linux-loongarch64-gnu@4.30.1':
'@rollup/rollup-linux-loongarch64-gnu@4.40.1':
optional: true
'@rollup/rollup-linux-powerpc64le-gnu@4.30.1':
'@rollup/rollup-linux-powerpc64le-gnu@4.40.1':
optional: true
'@rollup/rollup-linux-riscv64-gnu@4.30.1':
'@rollup/rollup-linux-riscv64-gnu@4.40.1':
optional: true
'@rollup/rollup-linux-s390x-gnu@4.30.1':
'@rollup/rollup-linux-riscv64-musl@4.40.1':
optional: true
'@rollup/rollup-linux-x64-gnu@4.30.1':
'@rollup/rollup-linux-s390x-gnu@4.40.1':
optional: true
'@rollup/rollup-linux-x64-musl@4.30.1':
'@rollup/rollup-linux-x64-gnu@4.40.1':
optional: true
'@rollup/rollup-win32-arm64-msvc@4.30.1':
'@rollup/rollup-linux-x64-musl@4.40.1':
optional: true
'@rollup/rollup-win32-ia32-msvc@4.30.1':
'@rollup/rollup-win32-arm64-msvc@4.40.1':
optional: true
'@rollup/rollup-win32-x64-msvc@4.30.1':
'@rollup/rollup-win32-ia32-msvc@4.40.1':
optional: true
'@rollup/rollup-win32-x64-msvc@4.40.1':
optional: true
'@rushstack/eslint-patch@1.10.5': {}
@@ -3781,6 +3840,8 @@ snapshots:
'@tanstack/virtual-core@3.11.2': {}
'@tanstack/virtual-core@3.13.6': {}
'@tanstack/vue-table@8.20.5(vue@3.5.13(typescript@5.7.3))':
dependencies:
'@tanstack/table-core': 8.20.5
@@ -3791,6 +3852,11 @@ snapshots:
'@tanstack/virtual-core': 3.11.2
vue: 3.5.13(typescript@5.7.3)
'@tanstack/vue-virtual@3.13.6(vue@3.5.13(typescript@5.7.3))':
dependencies:
'@tanstack/virtual-core': 3.13.6
vue: 3.5.13(typescript@5.7.3)
'@tiptap/core@2.11.2(@tiptap/pm@2.11.2)':
dependencies:
'@tiptap/pm': 2.11.2
@@ -4104,7 +4170,7 @@ snapshots:
'@types/dagre@0.7.52': {}
'@types/estree@1.0.6': {}
'@types/estree@1.0.7': {}
'@types/geojson@7946.0.15': {}
@@ -4184,6 +4250,8 @@ snapshots:
'@types/web-bluetooth@0.0.20': {}
'@types/web-bluetooth@0.0.21': {}
'@types/yauzl@2.10.3':
dependencies:
'@types/node': 22.10.5
@@ -4250,9 +4318,9 @@ snapshots:
transitivePeerDependencies:
- vue
'@vitejs/plugin-vue@5.2.1(vite@5.4.11(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0))(vue@3.5.13(typescript@5.7.3))':
'@vitejs/plugin-vue@5.2.1(vite@5.4.18(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0))(vue@3.5.13(typescript@5.7.3))':
dependencies:
vite: 5.4.11(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)
vite: 5.4.18(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)
vue: 3.5.13(typescript@5.7.3)
'@vue/compiler-core@3.5.13':
@@ -4357,10 +4425,21 @@ snapshots:
transitivePeerDependencies:
- typescript
'@vueuse/core@12.8.2(typescript@5.7.3)':
dependencies:
'@types/web-bluetooth': 0.0.21
'@vueuse/metadata': 12.8.2
'@vueuse/shared': 12.8.2(typescript@5.7.3)
vue: 3.5.13(typescript@5.7.3)
transitivePeerDependencies:
- typescript
'@vueuse/metadata@10.11.1': {}
'@vueuse/metadata@12.4.0': {}
'@vueuse/metadata@12.8.2': {}
'@vueuse/shared@10.11.1(vue@3.5.13(typescript@5.7.3))':
dependencies:
vue-demi: 0.14.10(vue@3.5.13(typescript@5.7.3))
@@ -4374,6 +4453,12 @@ snapshots:
transitivePeerDependencies:
- typescript
'@vueuse/shared@12.8.2(typescript@5.7.3)':
dependencies:
vue: 3.5.13(typescript@5.7.3)
transitivePeerDependencies:
- typescript
'@withtypes/mime@0.1.2':
dependencies:
mime: 3.0.0
@@ -4460,10 +4545,10 @@ snapshots:
aws4@1.13.2: {}
axios@1.7.9(debug@4.4.0):
axios@1.8.2(debug@4.4.0):
dependencies:
follow-redirects: 1.15.9(debug@4.4.0)
form-data: 4.0.1
form-data: 4.0.2
proxy-from-env: 1.1.0
transitivePeerDependencies:
- debug
@@ -4526,6 +4611,11 @@ snapshots:
es-errors: 1.3.0
function-bind: 1.1.2
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
function-bind: 1.1.2
call-bound@1.0.3:
dependencies:
call-bind-apply-helpers: 1.0.1
@@ -4928,7 +5018,7 @@ snapshots:
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.1
call-bind-apply-helpers: 1.0.2
es-errors: 1.3.0
gopd: 1.2.0
@@ -4974,6 +5064,17 @@ snapshots:
dependencies:
es-errors: 1.3.0
es-object-atoms@1.1.1:
dependencies:
es-errors: 1.3.0
es-set-tostringtag@2.1.0:
dependencies:
es-errors: 1.3.0
get-intrinsic: 1.3.0
has-tostringtag: 1.0.2
hasown: 2.0.2
esbuild@0.21.5:
optionalDependencies:
'@esbuild/aix-ppc64': 0.21.5
@@ -5217,17 +5318,18 @@ snapshots:
optionalDependencies:
debug: 4.4.0(supports-color@8.1.1)
foreground-child@3.3.0:
foreground-child@3.3.1:
dependencies:
cross-spawn: 7.0.6
signal-exit: 4.1.0
forever-agent@0.6.1: {}
form-data@4.0.1:
form-data@4.0.2:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
mime-types: 2.1.35
fraction.js@4.3.7: {}
@@ -5265,10 +5367,23 @@ snapshots:
hasown: 2.0.2
math-intrinsics: 1.1.0
get-intrinsic@1.3.0:
dependencies:
call-bind-apply-helpers: 1.0.2
es-define-property: 1.0.1
es-errors: 1.3.0
es-object-atoms: 1.1.1
function-bind: 1.1.2
get-proto: 1.0.1
gopd: 1.2.0
has-symbols: 1.1.0
hasown: 2.0.2
math-intrinsics: 1.1.0
get-proto@1.0.1:
dependencies:
dunder-proto: 1.0.1
es-object-atoms: 1.0.0
es-object-atoms: 1.1.1
get-stream@5.2.0:
dependencies:
@@ -5296,7 +5411,7 @@ snapshots:
glob@10.4.5:
dependencies:
foreground-child: 3.3.0
foreground-child: 3.3.1
jackspeak: 3.4.3
minimatch: 9.0.5
minipass: 7.1.2
@@ -5338,6 +5453,10 @@ snapshots:
has-symbols@1.1.0: {}
has-tostringtag@1.0.2:
dependencies:
has-symbols: 1.1.0
hasown@2.0.2:
dependencies:
function-bind: 1.1.2
@@ -5674,6 +5793,8 @@ snapshots:
object-inspect@1.13.3: {}
ohash@2.0.11: {}
once@1.4.0:
dependencies:
wrappy: 1.0.2
@@ -5768,7 +5889,7 @@ snapshots:
transitivePeerDependencies:
- '@vue/composition-api'
pirates@4.0.6: {}
pirates@4.0.7: {}
postcss-import@15.1.0(postcss@8.4.49):
dependencies:
@@ -5785,7 +5906,7 @@ snapshots:
postcss-load-config@4.0.2(postcss@8.4.49):
dependencies:
lilconfig: 3.1.3
yaml: 2.7.0
yaml: 2.7.1
optionalDependencies:
postcss: 8.4.49
@@ -5958,7 +6079,7 @@ snapshots:
quickselect@2.0.0: {}
radix-vue@1.9.12(vue@3.5.13(typescript@5.7.3)):
radix-vue@1.9.17(vue@3.5.13(typescript@5.7.3)):
dependencies:
'@floating-ui/dom': 1.6.13
'@floating-ui/vue': 1.1.6(vue@3.5.13(typescript@5.7.3))
@@ -5987,6 +6108,23 @@ snapshots:
regenerator-runtime@0.14.1: {}
reka-ui@2.2.0(typescript@5.7.3)(vue@3.5.13(typescript@5.7.3)):
dependencies:
'@floating-ui/dom': 1.6.13
'@floating-ui/vue': 1.1.6(vue@3.5.13(typescript@5.7.3))
'@internationalized/date': 3.6.0
'@internationalized/number': 3.6.0
'@tanstack/vue-virtual': 3.13.6(vue@3.5.13(typescript@5.7.3))
'@vueuse/core': 12.8.2(typescript@5.7.3)
'@vueuse/shared': 12.8.2(typescript@5.7.3)
aria-hidden: 1.2.4
defu: 6.1.4
ohash: 2.0.11
vue: 3.5.13(typescript@5.7.3)
transitivePeerDependencies:
- '@vue/composition-api'
- typescript
request-progress@3.0.0:
dependencies:
throttleit: 1.0.1
@@ -6018,29 +6156,30 @@ snapshots:
robust-predicates@3.0.2: {}
rollup@4.30.1:
rollup@4.40.1:
dependencies:
'@types/estree': 1.0.6
'@types/estree': 1.0.7
optionalDependencies:
'@rollup/rollup-android-arm-eabi': 4.30.1
'@rollup/rollup-android-arm64': 4.30.1
'@rollup/rollup-darwin-arm64': 4.30.1
'@rollup/rollup-darwin-x64': 4.30.1
'@rollup/rollup-freebsd-arm64': 4.30.1
'@rollup/rollup-freebsd-x64': 4.30.1
'@rollup/rollup-linux-arm-gnueabihf': 4.30.1
'@rollup/rollup-linux-arm-musleabihf': 4.30.1
'@rollup/rollup-linux-arm64-gnu': 4.30.1
'@rollup/rollup-linux-arm64-musl': 4.30.1
'@rollup/rollup-linux-loongarch64-gnu': 4.30.1
'@rollup/rollup-linux-powerpc64le-gnu': 4.30.1
'@rollup/rollup-linux-riscv64-gnu': 4.30.1
'@rollup/rollup-linux-s390x-gnu': 4.30.1
'@rollup/rollup-linux-x64-gnu': 4.30.1
'@rollup/rollup-linux-x64-musl': 4.30.1
'@rollup/rollup-win32-arm64-msvc': 4.30.1
'@rollup/rollup-win32-ia32-msvc': 4.30.1
'@rollup/rollup-win32-x64-msvc': 4.30.1
'@rollup/rollup-android-arm-eabi': 4.40.1
'@rollup/rollup-android-arm64': 4.40.1
'@rollup/rollup-darwin-arm64': 4.40.1
'@rollup/rollup-darwin-x64': 4.40.1
'@rollup/rollup-freebsd-arm64': 4.40.1
'@rollup/rollup-freebsd-x64': 4.40.1
'@rollup/rollup-linux-arm-gnueabihf': 4.40.1
'@rollup/rollup-linux-arm-musleabihf': 4.40.1
'@rollup/rollup-linux-arm64-gnu': 4.40.1
'@rollup/rollup-linux-arm64-musl': 4.40.1
'@rollup/rollup-linux-loongarch64-gnu': 4.40.1
'@rollup/rollup-linux-powerpc64le-gnu': 4.40.1
'@rollup/rollup-linux-riscv64-gnu': 4.40.1
'@rollup/rollup-linux-riscv64-musl': 4.40.1
'@rollup/rollup-linux-s390x-gnu': 4.40.1
'@rollup/rollup-linux-x64-gnu': 4.40.1
'@rollup/rollup-linux-x64-musl': 4.40.1
'@rollup/rollup-win32-arm64-msvc': 4.40.1
'@rollup/rollup-win32-ia32-msvc': 4.40.1
'@rollup/rollup-win32-x64-msvc': 4.40.1
fsevents: 2.3.3
rope-sequence@1.3.4: {}
@@ -6222,7 +6361,7 @@ snapshots:
glob: 10.4.5
lines-and-columns: 1.2.4
mz: 2.7.0
pirates: 4.0.6
pirates: 4.0.7
ts-interface-checker: 0.1.13
supercluster@7.1.5:
@@ -6389,11 +6528,11 @@ snapshots:
core-util-is: 1.0.2
extsprintf: 1.3.0
vite@5.4.11(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0):
vite@5.4.18(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0):
dependencies:
esbuild: 0.21.5
postcss: 8.4.49
rollup: 4.30.1
rollup: 4.40.1
optionalDependencies:
'@types/node': 22.10.5
fsevents: 2.3.3
@@ -6428,10 +6567,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
vue-i18n@9.14.2(vue@3.5.13(typescript@5.7.3)):
vue-i18n@9.14.3(vue@3.5.13(typescript@5.7.3)):
dependencies:
'@intlify/core-base': 9.14.2
'@intlify/shared': 9.14.2
'@intlify/core-base': 9.14.3
'@intlify/shared': 9.14.3
'@vue/devtools-api': 6.6.4
vue: 3.5.13(typescript@5.7.3)
@@ -6479,7 +6618,7 @@ snapshots:
wait-on@8.0.1(debug@4.4.0):
dependencies:
axios: 1.7.9(debug@4.4.0)
axios: 1.8.2(debug@4.4.0)
joi: 17.13.3
lodash: 4.17.21
minimist: 1.2.8
@@ -6521,7 +6660,7 @@ snapshots:
yaml@1.10.2: {}
yaml@2.7.0: {}
yaml@2.7.1: {}
yauzl@2.10.0:
dependencies:

View File

@@ -1,5 +1,5 @@
<template>
<div class="flex w-full h-screen">
<div class="flex w-full h-screen text-foreground">
<!-- Icon sidebar always visible -->
<SidebarProvider style="--sidebar-width: 3rem" class="w-auto z-50">
<ShadcnSidebar collapsible="none" class="border-r">
@@ -8,27 +8,64 @@
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild :isActive="route.path.startsWith('/inboxes')">
<router-link :to="{ name: 'inboxes' }">
<Inbox />
</router-link>
</SidebarMenuButton>
<Tooltip>
<TooltipTrigger as-child>
<SidebarMenuButton asChild :isActive="route.path.startsWith('/inboxes')">
<router-link :to="{ name: 'inboxes' }">
<Inbox />
</router-link>
</SidebarMenuButton>
</TooltipTrigger>
<TooltipContent side="right">
<p>{{ t('globals.terms.inbox', 2) }}</p>
</TooltipContent>
</Tooltip>
</SidebarMenuItem>
<SidebarMenuItem v-if="userStore.hasAdminTabPermissions">
<SidebarMenuButton asChild :isActive="route.path.startsWith('/admin')">
<router-link
:to="{ name: userStore.can('general_settings:manage') ? 'general' : 'admin' }"
>
<Shield />
</router-link>
</SidebarMenuButton>
<SidebarMenuItem v-if="userStore.can('contacts:read_all')">
<Tooltip>
<TooltipTrigger as-child>
<SidebarMenuButton asChild :isActive="route.path.startsWith('/contacts')">
<router-link :to="{ name: 'contacts' }">
<BookUser />
</router-link>
</SidebarMenuButton>
</TooltipTrigger>
<TooltipContent side="right">
<p>{{ t('globals.terms.contact', 2) }}</p>
</TooltipContent>
</Tooltip>
</SidebarMenuItem>
<SidebarMenuItem v-if="userStore.hasReportTabPermissions">
<SidebarMenuButton asChild :isActive="route.path.startsWith('/reports')">
<router-link :to="{ name: 'reports' }">
<FileLineChart />
</router-link>
</SidebarMenuButton>
<Tooltip>
<TooltipTrigger as-child>
<SidebarMenuButton asChild :isActive="route.path.startsWith('/reports')">
<router-link :to="{ name: 'reports' }">
<FileLineChart />
</router-link>
</SidebarMenuButton>
</TooltipTrigger>
<TooltipContent side="right">
<p>{{ t('globals.terms.report', 2) }}</p>
</TooltipContent>
</Tooltip>
</SidebarMenuItem>
<SidebarMenuItem v-if="userStore.hasAdminTabPermissions">
<Tooltip>
<TooltipTrigger as-child>
<SidebarMenuButton asChild :isActive="route.path.startsWith('/admin')">
<router-link
:to="{
name: userStore.can('general_settings:manage') ? 'general' : 'admin'
}"
>
<Shield />
</router-link>
</SidebarMenuButton>
</TooltipTrigger>
<TooltipContent side="right">
<p>{{ t('globals.terms.admin') }}</p>
</TooltipContent>
</Tooltip>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
@@ -69,7 +106,7 @@
<Command />
<!-- Create conversation dialog -->
<CreateConversation v-model="openCreateConversationDialog" />
<CreateConversation v-model="openCreateConversationDialog" v-if="openCreateConversationDialog" />
</template>
<script setup>
@@ -87,6 +124,7 @@ import { useTeamStore } from '@/stores/team'
import { useSlaStore } from '@/stores/sla'
import { useMacroStore } from '@/stores/macro'
import { useTagStore } from '@/stores/tag'
import { useCustomAttributeStore } from '@/stores/customAttributes'
import { useIdleDetection } from '@/composables/useIdleDetection'
import PageHeader from './components/layout/PageHeader.vue'
import ViewForm from '@/features/view/ViewForm.vue'
@@ -96,7 +134,8 @@ 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 { Inbox, Shield, FileLineChart, BookUser } from 'lucide-vue-next'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import {
Sidebar as ShadcnSidebar,
@@ -109,6 +148,7 @@ import {
SidebarMenuItem,
SidebarProvider
} from '@/components/ui/sidebar'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import SidebarNavUser from '@/components/sidebar/SidebarNavUser.vue'
const route = useRoute()
@@ -121,10 +161,12 @@ const inboxStore = useInboxStore()
const slaStore = useSlaStore()
const macroStore = useMacroStore()
const tagStore = useTagStore()
const customAttributeStore = useCustomAttributeStore()
const userViews = ref([])
const view = ref({})
const openCreateViewForm = ref(false)
const openCreateConversationDialog = ref(false)
const { t } = useI18n()
initWS()
useIdleDetection()
@@ -135,7 +177,7 @@ onMounted(() => {
initStores()
})
// initialize data stores
// Initialize data stores
const initStores = async () => {
if (!userStore.userID) {
await userStore.getCurrentUser()
@@ -149,7 +191,8 @@ const initStores = async () => {
inboxStore.fetchInboxes(),
slaStore.fetchSlas(),
macroStore.loadMacros(),
tagStore.fetchTags()
tagStore.fetchTags(),
customAttributeStore.fetchCustomAttributes()
])
}
@@ -163,8 +206,9 @@ const deleteView = async (view) => {
await api.deleteView(view.id)
emitter.emit(EMITTER_EVENTS.REFRESH_LIST, { model: 'view' })
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Success',
description: 'View deleted successfully'
description: t('globals.messages.deletedSuccessfully', {
name: t('globals.terms.view')
})
})
} catch (err) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
@@ -181,7 +225,6 @@ const getUserViews = async () => {
userViews.value = response.data.data
} catch (err) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(err).message
})

View File

@@ -1,9 +1,7 @@
<template>
<TooltipProvider :delay-duration="150">
<div class="!font-jakarta">
<Toaster class="pointer-events-auto" position="top-center" richColors />
<RouterView />
</div>
<Toaster class="pointer-events-auto" position="top-center" richColors />
<RouterView />
</TooltipProvider>
</template>

View File

@@ -33,12 +33,26 @@ http.interceptors.request.use((request) => {
return request
})
const getCustomAttributes = (appliesTo) => http.get('/api/v1/custom-attributes', {
params: { applies_to: appliesTo }
})
const createCustomAttribute = (data) =>
http.post('/api/v1/custom-attributes', data, {
headers: {
'Content-Type': 'application/json'
}
})
const getCustomAttribute = (id) => http.get(`/api/v1/custom-attributes/${id}`)
const updateCustomAttribute = (id, data) =>
http.put(`/api/v1/custom-attributes/${id}`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const deleteCustomAttribute = (id) => http.delete(`/api/v1/custom-attributes/${id}`)
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}`)
const getEmailNotificationSettings = () => http.get('/api/v1/settings/notifications/email')
const updateEmailNotificationSettings = (data) => http.put('/api/v1/settings/notifications/email', data)
const getPriorities = () => http.get('/api/v1/priorities')
@@ -119,31 +133,31 @@ const updateSettings = (key, data) =>
const getSettings = (key) => http.get(`/api/v1/settings/${key}`)
const login = (data) => http.post(`/api/v1/login`, data)
const getAutomationRules = (type) =>
http.get(`/api/v1/automation/rules`, {
http.get(`/api/v1/automations/rules`, {
params: { type: type }
})
const toggleAutomationRule = (id) => http.put(`/api/v1/automation/rules/${id}/toggle`)
const getAutomationRule = (id) => http.get(`/api/v1/automation/rules/${id}`)
const toggleAutomationRule = (id) => http.put(`/api/v1/automations/rules/${id}/toggle`)
const getAutomationRule = (id) => http.get(`/api/v1/automations/rules/${id}`)
const updateAutomationRule = (id, data) =>
http.put(`/api/v1/automation/rules/${id}`, data, {
http.put(`/api/v1/automations/rules/${id}`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const createAutomationRule = (data) =>
http.post(`/api/v1/automation/rules`, data, {
http.post(`/api/v1/automations/rules`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const deleteAutomationRule = (id) => http.delete(`/api/v1/automation/rules/${id}`)
const deleteAutomationRule = (id) => http.delete(`/api/v1/automations/rules/${id}`)
const updateAutomationRuleWeights = (data) =>
http.put(`/api/v1/automation/rules/weights`, data, {
http.put(`/api/v1/automations/rules/weights`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateAutomationRulesExecutionMode = (data) => http.put(`/api/v1/automation/rules/execution-mode`, data)
const updateAutomationRulesExecutionMode = (data) => http.put(`/api/v1/automations/rules/execution-mode`, data)
const getRoles = () => http.get('/api/v1/roles')
const getRole = (id) => http.get(`/api/v1/roles/${id}`)
const createRole = (data) =>
@@ -159,31 +173,69 @@ const updateRole = (id, data) =>
}
})
const deleteRole = (id) => http.delete(`/api/v1/roles/${id}`)
const getUser = (id) => http.get(`/api/v1/users/${id}`)
const getContacts = (params) => http.get('/api/v1/contacts', { params })
const getContact = (id) => http.get(`/api/v1/contacts/${id}`)
const updateContact = (id, data) => http.put(`/api/v1/contacts/${id}`, data, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
const blockContact = (id, data) => http.put(`/api/v1/contacts/${id}/block`, data)
const getTeam = (id) => http.get(`/api/v1/teams/${id}`)
const getTeams = () => http.get('/api/v1/teams')
const updateTeam = (id, data) => http.put(`/api/v1/teams/${id}`, data)
const createTeam = (data) => http.post('/api/v1/teams', data)
const getTeamsCompact = () => http.get('/api/v1/teams/compact')
const deleteTeam = (id) => http.delete(`/api/v1/teams/${id}`)
const getUsers = () => http.get('/api/v1/users')
const getUsersCompact = () => http.get('/api/v1/users/compact')
const updateUser = (id, data) =>
http.put(`/api/v1/agents/${id}`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const getUsers = () => http.get('/api/v1/agents')
const getUsersCompact = () => http.get('/api/v1/agents/compact')
const updateCurrentUser = (data) =>
http.put('/api/v1/users/me', data, {
http.put('/api/v1/agents/me', data, {
headers: {
'Content-Type': 'multipart/form-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 getUser = (id) => http.get(`/api/v1/agents/${id}`)
const deleteUserAvatar = () => http.delete('/api/v1/agents/me/avatar')
const getCurrentUser = () => http.get('/api/v1/agents/me')
const getCurrentUserTeams = () => http.get('/api/v1/agents/me/teams')
const updateCurrentUserAvailability = (data) => http.put('/api/v1/agents/me/availability', data)
const resetPassword = (data) => http.post('/api/v1/agents/reset-password', data)
const setPassword = (data) => http.post('/api/v1/agents/set-password', data)
const deleteUser = (id) => http.delete(`/api/v1/agents/${id}`)
const createUser = (data) =>
http.post('/api/v1/agents', data, {
headers: {
'Content-Type': 'application/json'
}
})
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 updateContactCustomAttribute = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/contacts/custom-attributes`, data,
{
headers: {
'Content-Type': 'application/json'
}
})
const updateConversationCustomAttribute = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/custom-attributes`, data,
{
headers: {
'Content-Type': 'application/json'
}
})
const createConversation = (data) => http.post('/api/v1/conversations', data, {
headers: {
'Content-Type': 'application/json'
}
})
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`)
@@ -231,18 +283,6 @@ const uploadMedia = (data) =>
const getOverviewCounts = () => http.get('/api/v1/reports/overview/counts')
const getOverviewCharts = () => http.get('/api/v1/reports/overview/charts')
const getLanguage = (lang) => http.get(`/api/v1/lang/${lang}`)
const createUser = (data) =>
http.post('/api/v1/users', data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateUser = (id, data) =>
http.put(`/api/v1/users/${id}`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const createInbox = (data) =>
http.post('/api/v1/inboxes', data, {
headers: {
@@ -276,6 +316,10 @@ 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)
const getContactNotes = (id) => http.get(`/api/v1/contacts/${id}/notes`)
const createContactNote = (id, data) => http.post(`/api/v1/contacts/${id}/notes`, data)
const deleteContactNote = (id, noteId) => http.delete(`/api/v1/contacts/${id}/notes/${noteId}`)
const getActivityLogs = (params) => http.get('/api/v1/activity-logs', { params })
export default {
login,
@@ -332,6 +376,8 @@ export default {
updateConversationStatus,
updateConversationPriority,
upsertTags,
updateConversationCustomAttribute,
updateContactCustomAttribute,
uploadMedia,
updateAssigneeLastSeen,
updateUser,
@@ -390,4 +436,17 @@ export default {
searchMessages,
searchContacts,
removeAssignee,
getContacts,
getContact,
updateContact,
blockContact,
getCustomAttributes,
createCustomAttribute,
updateCustomAttribute,
deleteCustomAttribute,
getCustomAttribute,
getContactNotes,
createContactNote,
deleteContactNote,
getActivityLogs
}

View File

@@ -13,12 +13,20 @@
min-height: 100%;
overflow: hidden;
margin: 0;
@apply bg-background text-foreground;
}
@media (max-width: 768px) {
@media (max-width: 768px) {
html,
body {
overflow-x: auto;
}
}
* {
@apply border-border;
}
.native-html {
p {
margin-bottom: 0.5rem;
@@ -61,10 +69,39 @@
}
}
}
}
:root {
--sidebar-background: 0 0% 100%;
--sidebar-foreground: 240 5.9% 10%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
.dark {
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
:root {
--vis-tooltip-background-color: none !important;
--vis-tooltip-border-color: none !important;
--vis-tooltip-text-color: none !important;
--vis-tooltip-shadow-color: none !important;
--vis-tooltip-backdrop-filter: none !important;
--vis-tooltip-padding: none !important;
--vis-primary-color: var(--primary);
--vis-secondary-color: 160 81% 40%;
--vis-text-color: var(--muted-foreground);
}
// Theme.
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
@@ -93,11 +130,11 @@
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 5.9% 10%;
--radius: 0.75rem;
--radius: 0.5rem;
}
.dark {
--background: 240 10% 3.9%;
--background: 240 5.9% 10%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
@@ -127,72 +164,8 @@
}
}
@layer base {
:root {
--vis-tooltip-background-color: none !important;
--vis-tooltip-border-color: none !important;
--vis-tooltip-text-color: none !important;
--vis-tooltip-shadow-color: none !important;
--vis-tooltip-backdrop-filter: none !important;
--vis-tooltip-padding: none !important;
--vis-primary-color: var(--primary);
--vis-secondary-color: 160 81% 40%;
--vis-text-color: var(--muted-foreground);
}
}
// Shake animation
@keyframes shake {
0% {
transform: translateX(0);
}
15% {
transform: translateX(-5px);
}
25% {
transform: translateX(5px);
}
35% {
transform: translateX(-5px);
}
45% {
transform: translateX(5px);
}
55% {
transform: translateX(-5px);
}
65% {
transform: translateX(5px);
}
75% {
transform: translateX(-5px);
}
85% {
transform: translateX(5px);
}
95% {
transform: translateX(-5px);
}
100% {
transform: translateX(0);
}
}
.animate-shake {
animation: shake 0.5s infinite;
}
.message-bubble {
@apply flex
flex-col
px-4
pt-2
pb-3
min-w-[30%] max-w-[70%]
border
overflow-x-auto
rounded-xl;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
@apply flex flex-col px-4 pt-2 pb-3 w-fit min-w-[30%] max-w-full border overflow-x-auto rounded shadow-sm;
table {
width: 100% !important;
table-layout: fixed !important;
@@ -208,7 +181,7 @@
}
.box {
@apply border shadow rounded-lg;
@apply border shadow rounded;
}
// Scrollbar start
@@ -235,84 +208,9 @@
// End Scrollbar
.code-editor {
@apply rounded-md border shadow h-[65vh] min-h-[250px] w-full relative;
@apply rounded border shadow h-[65vh] min-h-[250px] w-full relative;
}
.ql-container {
margin: 0 !important;
}
.ql-container .ql-editor {
height: 300px !important;
border-radius: var(--radius) !important;
@apply rounded-lg rounded-t-none;
}
.ql-toolbar {
@apply rounded-t-lg;
}
.blinking-dot {
display: inline-block;
width: 8px;
height: 8px;
background-color: red;
border-radius: 50%;
animation: blink 2s infinite;
}
@keyframes blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0;
}
}
// Sidebar start
@layer base {
:root {
--sidebar-background: 0 0% 96%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
.dark {
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
}
a[data-active='true'] {
background-color: hsl(var(--sidebar-background)) !important;
color: hsl(var(--sidebar-accent-foreground)) !important;
font-weight: 500;
transition:
background-color 0.2s,
color 0.2s;
}
a[data-active='false']:hover {
background-color: hsl(var(--sidebar-accent)) !important;
color: hsl(var(--sidebar-accent-foreground)) !important;
font-weight: 500;
transition:
background-color 0.2s,
color 0.2s;
}
// Sidebar end
.show-quoted-text {
blockquote {
@apply block;
@@ -325,37 +223,13 @@ a[data-active='false']:hover {
}
}
.dot-loader {
display: inline-flex;
align-items: center;
}
.dot {
width: 4px;
height: 4px;
border-radius: 50%;
background-color: currentColor;
margin: 0 2px;
animation: dot-flashing 1s infinite linear alternate;
}
.dot:nth-child(2) {
animation-delay: 0.2s;
}
.dot:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes dot-flashing {
0% {
opacity: 0.2;
}
100% {
opacity: 1;
}
}
[data-radix-popper-content-wrapper] {
z-index: 9999 !important;
}
// Components
@layer components {
.link-style {
@apply text-blue-500 hover:underline;
}
}

View File

@@ -0,0 +1,24 @@
<template>
<Button
variant="ghost"
@click.prevent="onClose"
size="xs"
class="text-gray-400 dark:text-gray-500 hover:text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800 w-6 h-6 p-0"
>
<slot>
<X size="16" />
</slot>
</Button>
</template>
<script setup>
import { Button } from '@/components/ui/button'
import { X } from 'lucide-vue-next'
defineProps({
onClose: {
type: Function,
required: true
}
})
</script>

View File

@@ -0,0 +1,61 @@
<template>
<ComboBox
:model-value="normalizedValue"
@update:model-value="$emit('update:modelValue', $event)"
:items="items"
:placeholder="placeholder"
>
<!-- Items -->
<template #item="{ item }">
<div class="flex items-center gap-2">
<!--USER -->
<Avatar v-if="type === 'user'" class="w-7 h-7">
<AvatarImage :src="item.avatar_url || ''" :alt="item.label.slice(0, 2)" />
<AvatarFallback>{{ item.label.slice(0, 2).toUpperCase() }}</AvatarFallback>
</Avatar>
<!-- Others -->
<span v-else-if="item.emoji">{{ item.emoji }}</span>
<span>{{ item.label }}</span>
</div>
</template>
<!-- Selected -->
<template #selected="{ selected }">
<div class="flex items-center gap-2">
<div v-if="selected" class="flex items-center gap-2">
<!--USER -->
<Avatar v-if="type === 'user'" class="w-7 h-7">
<AvatarImage :src="selected.avatar_url || ''" :alt="selected.label.slice(0, 2)" />
<AvatarFallback>{{ selected.label.slice(0, 2).toUpperCase() }}</AvatarFallback>
</Avatar>
<!-- Others -->
<span v-else-if="selected.emoji">{{ selected.emoji }}</span>
<span>{{ selected.label }}</span>
</div>
<span v-else>{{ placeholder }}</span>
</div>
</template>
</ComboBox>
</template>
<script setup>
import { computed } from 'vue'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
const props = defineProps({
modelValue: [String, Number, Object],
placeholder: String,
items: Array,
type: {
type: String
}
})
// Convert to str.
const normalizedValue = computed(() => String(props.modelValue || ''))
defineEmits(['update:modelValue'])
</script>

View File

@@ -1,19 +1,26 @@
<template>
<div class="w-full">
<div class="rounded-md border shadow">
<div class="rounded border shadow">
<Table>
<TableHeader>
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<TableHead v-for="header in headerGroup.headers" :key="header.id" class="font-semibold">
<FlexRender v-if="!header.isPlaceholder" :render="header.column.columnDef.header"
:props="header.getContext()" />
<FlexRender
v-if="!header.isPlaceholder"
:render="header.column.columnDef.header"
:props="header.getContext()"
/>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<template v-if="table.getRowModel().rows?.length">
<TableRow v-for="row in table.getRowModel().rows" :key="row.id"
:data-state="row.getIsSelected() ? 'selected' : undefined" class="hover:bg-muted/50">
<TableRow
v-for="row in table.getRowModel().rows"
:key="row.id"
:data-state="row.getIsSelected() ? 'selected' : undefined"
class="hover:bg-muted/50"
>
<TableCell v-for="cell in row.getVisibleCells()" :key="cell.id">
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
</TableCell>
@@ -32,9 +39,10 @@
</div>
</template>
<script setup>
import { FlexRender, getCoreRowModel, useVueTable } from '@tanstack/vue-table'
import { useI18n } from 'vue-i18n'
import { computed } from 'vue'
import {
Table,
@@ -45,20 +53,30 @@ import {
TableRow
} from '@/components/ui/table'
const { t } = useI18n()
const props = defineProps({
columns: Array,
data: Array,
emptyText: {
type: String,
default: 'No results.'
default: ''
}
})
// Set the default value for emptyText if it's empty
const emptyText = computed(
() =>
props.emptyText ||
t('globals.messages.noResults', {
name: t('globals.terms.result', 2).toLowerCase()
})
)
const table = useVueTable({
get data () {
get data() {
return props.data
},
get columns () {
get columns() {
return props.columns
},
getCoreRowModel: getCoreRowModel()

View File

@@ -4,7 +4,7 @@
:editor="editor"
:tippy-options="{ duration: 100 }"
v-if="editor"
class="bg-white p-1 box will-change-transform"
class="bg-background p-1 box will-change-transform"
>
<div class="flex space-x-1 items-center">
<DropdownMenu v-if="aiPrompts.length > 0">
@@ -32,7 +32,7 @@
variant="ghost"
@click.prevent="isBold = !isBold"
:active="isBold"
:class="{ 'bg-gray-200': isBold }"
:class="{ 'bg-gray-200 dark:bg-secondary': isBold }"
>
<Bold size="14" />
</Button>
@@ -41,7 +41,7 @@
variant="ghost"
@click.prevent="isItalic = !isItalic"
:active="isItalic"
:class="{ 'bg-gray-200': isItalic }"
:class="{ 'bg-gray-200 dark:bg-secondary': isItalic }"
>
<Italic size="14" />
</Button>
@@ -49,7 +49,7 @@
size="sm"
variant="ghost"
@click.prevent="toggleBulletList"
:class="{ 'bg-gray-200': editor?.isActive('bulletList') }"
:class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('bulletList') }"
>
<List size="14" />
</Button>
@@ -58,7 +58,7 @@
size="sm"
variant="ghost"
@click.prevent="toggleOrderedList"
:class="{ 'bg-gray-200': editor?.isActive('orderedList') }"
:class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('orderedList') }"
>
<ListOrdered size="14" />
</Button>
@@ -66,16 +66,16 @@
size="sm"
variant="ghost"
@click.prevent="openLinkModal"
:class="{ 'bg-gray-200': editor?.isActive('link') }"
:class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('link') }"
>
<LinkIcon size="14" />
</Button>
<div v-if="showLinkInput" class="flex space-x-2 p-2 bg-white border rounded-lg">
<input
<div v-if="showLinkInput" class="flex space-x-2 p-2 bg-background border rounded">
<Input
v-model="linkUrl"
type="text"
placeholder="Enter link URL"
class="border p-1 text-sm"
class="border p-1 text-sm w-[200px]"
/>
<Button size="sm" @click="setLink">
<Check size="14" />
@@ -91,7 +91,7 @@
</template>
<script setup>
import { ref, watch, watchEffect, onUnmounted } from 'vue'
import { ref, watch, watchEffect, onUnmounted, computed } from 'vue'
import { useEditor, EditorContent, BubbleMenu } from '@tiptap/vue-3'
import {
ChevronDown,
@@ -111,6 +111,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
import Placeholder from '@tiptap/extension-placeholder'
import Image from '@tiptap/extension-image'
import StarterKit from '@tiptap/starter-kit'
@@ -135,6 +136,10 @@ const props = defineProps({
setInlineImage: Object,
insertContent: String,
clearContent: Boolean,
autoFocus: {
type: Boolean,
default: true
},
aiPrompts: {
type: Array,
default: () => []
@@ -187,7 +192,7 @@ const CustomTableHeader = TableHeader.extend({
}
})
const editorConfig = {
const editorConfig = computed(() => ({
extensions: [
StarterKit.configure(),
Image.configure({ HTMLAttributes: { class: 'inline-image' } }),
@@ -200,7 +205,7 @@ const editorConfig = {
CustomTableCell,
CustomTableHeader
],
autofocus: true,
autofocus: props.autoFocus,
editorProps: {
attributes: { class: 'outline-none' },
handleKeyDown: (view, event) => {
@@ -208,13 +213,18 @@ const editorConfig = {
emit('send')
return true
}
if (event.ctrlKey && event.key.toLowerCase() === 'b') {
// Prevent outer listeners
event.stopPropagation()
return false
}
}
}
}
}))
const editor = ref(
useEditor({
...editorConfig,
...editorConfig.value,
content: htmlContent.value,
onSelectionUpdate: ({ editor }) => {
const { from, to } = editor.state.selection

View File

@@ -0,0 +1,205 @@
<template>
<div class="space-y-4">
<div class="w-[27rem]" v-if="modelValue.length === 0"></div>
<div
v-for="(modelFilter, index) in modelValue"
:key="index"
class="group flex items-center gap-3"
>
<div class="flex gap-2 w-full">
<!-- Field -->
<div class="flex-1">
<Select v-model="modelFilter.field">
<SelectTrigger class="bg-transparent hover:bg-slate-100 w-full">
<SelectValue :placeholder="t('form.field.selectField')" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="field in fields" :key="field.field" :value="field.field">
{{ field.label }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
<!-- Operator -->
<div class="flex-1">
<Select v-model="modelFilter.operator" v-if="modelFilter.field">
<SelectTrigger class="bg-transparent hover:bg-slate-100 w-full">
<SelectValue :placeholder="t('form.field.selectOperator')" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="op in getFieldOperators(modelFilter)" :key="op" :value="op">
{{ op }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
<!-- Value -->
<div class="flex-1">
<div v-if="modelFilter.field && modelFilter.operator">
<template v-if="modelFilter.operator !== 'set' && modelFilter.operator !== 'not set'">
<SelectComboBox
v-if="
getFieldOptions(modelFilter).length > 0 &&
modelFilter.field === 'assigned_user_id'
"
v-model="modelFilter.value"
:items="getFieldOptions(modelFilter)"
:placeholder="t('form.field.select')"
type="user"
/>
<SelectComboBox
v-else-if="
getFieldOptions(modelFilter).length > 0 &&
modelFilter.field === 'assigned_team_id'
"
v-model="modelFilter.value"
:items="getFieldOptions(modelFilter)"
:placeholder="t('form.field.select')"
type="team"
/>
<SelectComboBox
v-else-if="getFieldOptions(modelFilter).length > 0"
v-model="modelFilter.value"
:items="getFieldOptions(modelFilter)"
:placeholder="t('form.field.select')"
/>
<Input
v-else
v-model="modelFilter.value"
class="bg-transparent hover:bg-slate-100"
:placeholder="t('globals.terms.value')"
type="text"
/>
</template>
</div>
</div>
</div>
<CloseButton :onClose="() => removeFilter(index)" />
</div>
<div class="flex items-center justify-between pt-3">
<Button variant="ghost" size="sm" @click="addFilter" class="text-slate-600">
<Plus class="w-3 h-3 mr-1" />
{{
$t('globals.messages.add', {
name: $t('globals.terms.filter')
})
}}
</Button>
<div class="flex gap-2" v-if="showButtons">
<Button variant="ghost" @click="clearFilters">{{ $t('globals.buttons.reset') }}</Button>
<Button @click="applyFilters">{{ $t('globals.buttons.apply') }}</Button>
</div>
</div>
</div>
</template>
<script setup>
import { computed, onMounted, watch } from 'vue'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { Plus } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useI18n } from 'vue-i18n'
import CloseButton from '@/components/button/CloseButton.vue'
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
const props = defineProps({
fields: {
type: Array,
required: true
},
showButtons: {
type: Boolean,
default: true
}
})
const { t } = useI18n()
const emit = defineEmits(['apply', 'clear'])
const modelValue = defineModel('modelValue', { required: false, default: () => [] })
const createFilter = () => ({ field: '', operator: '', value: '' })
onMounted(() => {
if (modelValue.value.length === 0) {
modelValue.value = [createFilter()]
}
})
const getModel = (field) => {
const fieldConfig = props.fields.find((f) => f.field === field)
return fieldConfig?.model || ''
}
// Set model for each filter
watch(
() => modelValue.value,
(filters) => {
filters.forEach((filter) => {
if (filter.field && !filter.model) {
filter.model = getModel(filter.field)
}
})
},
{ deep: true }
)
// Reset operator and value when field changes for a filter at a given index
watch(
() => modelValue.value.map((f) => f.field),
(newFields, oldFields) => {
newFields.forEach((field, index) => {
if (field !== oldFields[index]) {
modelValue.value[index].operator = ''
modelValue.value[index].value = ''
}
})
}
)
const addFilter = () => {
modelValue.value = [...modelValue.value, createFilter()]
}
const removeFilter = (index) => {
modelValue.value = modelValue.value.filter((_, i) => i !== index)
}
const applyFilters = () => {
modelValue.value = validFilters.value
emit('apply', modelValue.value)
}
const clearFilters = () => {
modelValue.value = []
emit('clear')
}
const validFilters = computed(() => {
return modelValue.value.filter((filter) => filter.field && filter.operator && filter.value)
})
const getFieldOptions = (fieldValue) => {
const field = props.fields.find((f) => f.field === fieldValue.field)
return field?.options || []
}
const getFieldOperators = (modelFilter) => {
const field = props.fields.find((f) => f.field === modelFilter.field)
return field?.operators || []
}
</script>

View File

@@ -1,6 +1,6 @@
<template>
<div
class="flex flex-col p-4 border rounded-lg shadow-sm hover:shadow transition-colors cursor-pointer max-w-xs"
class="flex flex-col p-4 border rounded shadow-sm hover:shadow transition-colors cursor-pointer max-w-xs"
@click="handleClick">
<div class="flex items-center mb-2">
<component :is="icon" size="24" class="mr-2 text-primary" />

View File

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

View File

@@ -1,5 +1,10 @@
<script setup>
import { adminNavItems, reportsNavItems, accountNavItems } from '@/constants/navigation'
import {
adminNavItems,
reportsNavItems,
accountNavItems,
contactNavItems
} from '@/constants/navigation'
import { RouterLink, useRoute } from 'vue-router'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import {
@@ -9,7 +14,6 @@ import {
SidebarHeader,
SidebarInset,
SidebarMenu,
SidebarSeparator,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
@@ -22,11 +26,11 @@ import { useAppSettingsStore } from '@/stores/appSettings'
import {
ChevronRight,
EllipsisVertical,
User,
Search,
Plus,
CircleUserRound,
UserSearch,
UsersRound,
Search
CircleDashed,
List
} from 'lucide-vue-next'
import {
DropdownMenu,
@@ -37,6 +41,7 @@ import {
import { filterNavItems } from '@/utils/nav-permissions'
import { useStorage } from '@vueuse/core'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useUserStore } from '@/stores/user'
defineProps({
@@ -46,6 +51,7 @@ defineProps({
const userStore = useUserStore()
const settingsStore = useAppSettingsStore()
const route = useRoute()
const { t } = useI18n()
const emit = defineEmits(['createView', 'editView', 'deleteView', 'createConversation'])
const openCreateViewDialog = () => {
@@ -62,6 +68,7 @@ const deleteView = (view) => {
const filteredAdminNavItems = computed(() => filterNavItems(adminNavItems, userStore.can))
const filteredReportsNavItems = computed(() => filterNavItems(reportsNavItems, userStore.can))
const filteredContactsNavItems = computed(() => filterNavItems(contactNavItems, userStore.can))
const isActiveParent = (parentHref) => {
return route.path.startsWith(parentHref)
@@ -82,6 +89,43 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
:default-open="sidebarOpen"
v-on:update:open="sidebarOpen = $event"
>
<!-- Contacts sidebar -->
<template
v-if="route.matched.some((record) => record.name && record.name.startsWith('contact'))"
>
<Sidebar collapsible="offcanvas" class="border-r ml-12">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<div class="px-1">
<span class="font-semibold text-xl">
{{ t('globals.terms.contact', 2) }}
</span>
</div>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarMenu>
<SidebarMenuItem v-for="item in filteredContactsNavItems" :key="item.titleKey">
<SidebarMenuButton :isActive="isActiveParent(item.href)" asChild>
<router-link :to="item.href">
<span>{{
t('globals.messages.all', {
name: t(item.titleKey, 2).toLowerCase()
})
}}</span>
</router-link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
<SidebarRail />
</Sidebar>
</template>
<!-- Reports sidebar -->
<template
v-if="
@@ -93,22 +137,21 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton :isActive="isActiveParent('/reports/overview')" asChild>
<div>
<span class="font-semibold text-xl">Reports</span>
</div>
</SidebarMenuButton>
<div class="px-1">
<span class="font-semibold text-xl">
{{ t('globals.terms.report', 2) }}
</span>
</div>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarSeparator />
<SidebarContent>
<SidebarGroup>
<SidebarMenu>
<SidebarMenuItem v-for="item in filteredReportsNavItems" :key="item.title">
<SidebarMenuItem v-for="item in filteredReportsNavItems" :key="item.titleKey">
<SidebarMenuButton :isActive="isActiveParent(item.href)" asChild>
<router-link :to="item.href">
<span>{{ item.title }}</span>
<span>{{ t(item.titleKey) }}</span>
</router-link>
</SidebarMenuButton>
</SidebarMenuItem>
@@ -125,30 +168,29 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton :isActive="isActiveParent('/admin')" asChild>
<div class="flex items-center justify-between w-full">
<span class="font-semibold text-xl">Admin</span>
</div>
<div class="flex flex-col items-start justify-between w-full px-1">
<span class="font-semibold text-xl">
{{ t('globals.terms.admin') }}
</span>
<!-- App version -->
<div class="text-xs text-muted-foreground ml-2">
<div class="text-xs text-muted-foreground">
({{ settingsStore.settings['app.version'] }})
</div>
</SidebarMenuButton>
</div>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarSeparator />
<SidebarContent>
<SidebarGroup>
<SidebarMenu>
<SidebarMenuItem v-for="item in filteredAdminNavItems" :key="item.title">
<SidebarMenuItem v-for="item in filteredAdminNavItems" :key="item.titleKey">
<SidebarMenuButton
v-if="!item.children"
:isActive="isActiveParent(item.href)"
asChild
>
<router-link :to="item.href">
<span>{{ item.title }}</span>
<span>{{ t(item.titleKey) }}</span>
</router-link>
</SidebarMenuButton>
@@ -159,7 +201,7 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
>
<CollapsibleTrigger as-child>
<SidebarMenuButton :isActive="isActiveParent(item.href)">
<span>{{ item.title }}</span>
<span>{{ t(item.titleKey) }}</span>
<ChevronRight
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
/>
@@ -167,10 +209,10 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
<SidebarMenuSubItem v-for="child in item.children" :key="child.title">
<SidebarMenuSubItem v-for="child in item.children" :key="child.titleKey">
<SidebarMenuButton size="sm" :isActive="isActiveParent(child.href)" asChild>
<router-link :to="child.href">
<span>{{ child.title }}</span>
<span>{{ t(child.titleKey) }}</span>
</router-link>
</SidebarMenuButton>
</SidebarMenuSubItem>
@@ -191,22 +233,21 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton :isActive="isActiveParent('/account/profile')" asChild>
<div>
<span class="font-semibold text-xl">Account</span>
</div>
</SidebarMenuButton>
<div class="px-1">
<span class="font-semibold text-xl">
{{ t('globals.terms.account') }}
</span>
</div>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarSeparator />
<SidebarContent>
<SidebarGroup>
<SidebarMenu>
<SidebarMenuItem v-for="item in accountNavItems" :key="item.title">
<SidebarMenuItem v-for="item in accountNavItems" :key="item.titleKey">
<SidebarMenuButton :isActive="isActiveParent(item.href)" asChild>
<router-link :to="item.href">
<span>{{ item.title }}</span>
<span>{{ t(item.titleKey) }}</span>
</router-link>
</SidebarMenuButton>
<SidebarMenuAction>
@@ -226,46 +267,42 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild>
<div class="flex items-center justify-between w-full">
<div class="font-semibold text-xl">Inbox</div>
<div class="ml-auto">
<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 :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 class="flex items-center justify-between w-full px-1">
<div class="font-semibold text-xl">
<span>{{ t('globals.terms.inbox') }}</span>
</div>
</SidebarMenuButton>
<div class="mr-1 mt-1 hover:scale-110 transition-transform">
<router-link :to="{ name: 'search' }">
<Search size="18" stroke-width="2.5" />
</router-link>
</div>
</div>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarSeparator />
<SidebarContent>
<SidebarGroup>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild>
<a href="#" @click="emit('createConversation')">
<Plus />
<span
>{{
t('globals.messages.new', {
name: t('globals.terms.conversation').toLowerCase()
})
}}
</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild :isActive="isActiveParent('/inboxes/assigned')">
<router-link :to="{ name: 'inbox', params: { type: 'assigned' } }">
<CircleUserRound />
<span>My inbox</span>
<User />
<span>{{ t('globals.terms.myInbox') }}</span>
</router-link>
</SidebarMenuButton>
</SidebarMenuItem>
@@ -273,8 +310,10 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
<SidebarMenuItem>
<SidebarMenuButton asChild :isActive="isActiveParent('/inboxes/unassigned')">
<router-link :to="{ name: 'inbox', params: { type: 'unassigned' } }">
<UserSearch />
<span>Unassigned</span>
<CircleDashed />
<span>
{{ t('globals.terms.unassigned') }}
</span>
</router-link>
</SidebarMenuButton>
</SidebarMenuItem>
@@ -282,8 +321,10 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
<SidebarMenuItem>
<SidebarMenuButton asChild :isActive="isActiveParent('/inboxes/all')">
<router-link :to="{ name: 'inbox', params: { type: 'all' } }">
<UsersRound />
<span>All</span>
<List />
<span>
{{ t('globals.messages.all') }}
</span>
</router-link>
</SidebarMenuButton>
</SidebarMenuItem>
@@ -300,7 +341,9 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
<SidebarMenuButton asChild>
<router-link to="#">
<!-- <Users /> -->
<span>Team inboxes</span>
<span>
{{ t('globals.terms.teamInbox', 2) }}
</span>
<ChevronRight
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
/>
@@ -328,16 +371,18 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
<!-- Views -->
<Collapsible class="group/collapsible" defaultOpen v-model:open="viewInboxOpen">
<SidebarMenuItem>
<CollapsibleTrigger as-child>
<CollapsibleTrigger asChild>
<SidebarMenuButton asChild>
<router-link to="#">
<router-link to="#" class="group/item !p-2">
<!-- <SlidersHorizontal /> -->
<span>Views</span>
<span>
{{ t('globals.terms.view', 2) }}
</span>
<div>
<Plus
size="18"
@click.stop="openCreateViewDialog"
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"
class="rounded cursor-pointer opacity-0 transition-all duration-200 group-hover/item: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
@@ -365,10 +410,10 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="() => editView(view)">
<span>Edit</span>
<span>{{ t('globals.buttons.edit') }}</span>
</DropdownMenuItem>
<DropdownMenuItem @click="() => deleteView(view)">
<span>Delete</span>
<span>{{ t('globals.buttons.delete') }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -2,12 +2,12 @@
<DropdownMenu>
<DropdownMenuTrigger as-child>
<SidebarMenuButton
size="lg"
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground p-0"
size="md"
class="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">
<Avatar class="h-8 w-8 rounded relative overflow-visible">
<AvatarImage :src="userStore.avatar" alt="U" class="rounded" />
<AvatarFallback class="rounded">
{{ userStore.getInitials }}
</AvatarFallback>
<div
@@ -16,7 +16,8 @@
'bg-green-500': userStore.user.availability_status === 'online',
'bg-amber-500':
userStore.user.availability_status === 'away' ||
userStore.user.availability_status === 'away_manual',
userStore.user.availability_status === 'away_manual' ||
userStore.user.availability_status === 'away_and_reassigning',
'bg-gray-400': userStore.user.availability_status === 'offline'
}"
></div>
@@ -29,51 +30,86 @@
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
class="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
class="w-[--radix-dropdown-menu-trigger-width] min-w-56"
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">
<DropdownMenuLabel class="font-normal space-y-2 px-2">
<!-- User header -->
<div class="flex items-center gap-2 py-1.5 text-left text-sm">
<Avatar class="h-8 w-8 rounded">
<AvatarImage :src="userStore.avatar" alt="U" />
<AvatarFallback class="rounded">
{{ userStore.getInitials }}
</AvatarFallback>
</Avatar>
<div class="grid flex-1 text-left text-sm leading-tight">
<div class="flex-1 flex flex-col leading-tight">
<span class="truncate font-semibold">{{ userStore.getFullName }}</span>
<span class="truncate text-xs">{{ userStore.email }}</span>
<span class="truncate text-xs text-muted-foreground">{{ 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 class="space-y-2">
<!-- Dark-mode toggle -->
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2">
<Moon v-if="mode === 'dark'" size="16" class="text-muted-foreground" />
<Sun v-else size="16" class="text-muted-foreground" />
<span class="text-muted-foreground">{{ t('navigation.darkMode') }}</span>
</div>
<Switch
:checked="mode === 'dark'"
@update:checked="(val) => (mode = val ? 'dark' : 'light')"
/>
</div>
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 space-y-3">
<!-- Away toggle -->
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">{{ t('navigation.away') }}</span>
<Switch
:checked="
['away_manual', 'away_and_reassigning'].includes(
userStore.user.availability_status
)
"
@update:checked="
(val) => userStore.updateUserAvailability(val ? 'away_manual' : 'online')
"
/>
</div>
<!-- Reassign toggle -->
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">{{ t('navigation.reassignReplies') }}</span>
<Switch
:checked="userStore.user.availability_status === 'away_and_reassigning'"
@update:checked="
(val) =>
userStore.updateUserAvailability(val ? 'away_and_reassigning' : 'away_manual')
"
/>
</div>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem @click.prevent="router.push({ name: 'account' })">
<CircleUserRound size="18" class="mr-2" />
Account
{{ t('globals.terms.account') }}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem @click="logout">
<LogOut size="18" class="mr-2" />
Log out
{{ t('navigation.logout') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
import {
DropdownMenu,
DropdownMenuContent,
@@ -86,12 +122,16 @@ import {
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 { ChevronsUpDown, CircleUserRound, LogOut, Moon, Sun } from 'lucide-vue-next'
import { useUserStore } from '@/stores/user'
import { useRouter } from 'vue-router'
import { useColorMode } from '@vueuse/core'
const mode = useColorMode()
const userStore = useUserStore()
const router = useRouter()
const { t } = useI18n()
const logout = () => {
window.location.href = '/logout'

View File

@@ -1,38 +1,44 @@
<template>
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<table class="min-w-full table-fixed divide-y divide-border">
<thead class="bg-muted">
<tr>
<th
v-for="(header, index) in headers"
:key="index"
scope="col"
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
class="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider"
>
{{ header }}
</th>
<th scope="col" class="relative px-6 py-3"></th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tbody class="bg-background divide-y divide-border">
<template v-if="data.length === 0">
<tr>
<td :colspan="headers.length + 1" class="px-6 py-12 text-center">
<div class="flex flex-col items-center space-y-4">
<span class="text-md text-gray-500"> No records found. </span>
<span class="text-md text-muted-foreground">
{{
$t('globals.messages.noResults', {
name: $t('globals.terms.result', 2).toLowerCase()
})
}}
</span>
</div>
</td>
</tr>
</template>
<template v-else>
<tr v-for="(item, index) in data" :key="index">
<tr v-for="(item, index) in data" :key="index" class="hover:bg-accent">
<td
v-for="key in keys"
:key="key"
class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900"
class="px-6 py-4 text-sm font-medium text-foreground whitespace-normal break-words"
>
{{ item[key] }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<td v-if="showDelete" class="px-6 py-4 text-sm text-muted-foreground">
<Button size="xs" variant="ghost" @click.prevent="deleteItem(item)">
<Trash2 class="h-4 w-4" />
</Button>
@@ -63,6 +69,10 @@ defineProps({
type: Array,
required: true,
default: () => []
},
showDelete: {
type: Boolean,
default: true
}
})

View File

@@ -2,7 +2,7 @@
import { AvatarImage } from 'radix-vue'
const props = defineProps({
src: { type: String, required: true },
src: { type: String, required: false, default: '' },
asChild: { type: Boolean, required: false },
as: { type: null, required: false }
})

View File

@@ -0,0 +1,59 @@
<template>
<div class="relative group w-28 h-28 cursor-pointer" @click="triggerFileInput">
<Avatar class="size-28">
<AvatarImage :src="src || ''" />
<AvatarFallback>{{ initials }}</AvatarFallback>
</Avatar>
<!-- Hover Overlay -->
<div
class="absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer rounded-full"
>
<span class="text-white font-semibold">{{ label }}</span>
</div>
<!-- Delete Icon -->
<X
class="absolute top-1 right-1 rounded-full p-1 shadow-md z-10 opacity-0 group-hover:opacity-100 transition-opacity"
size="20"
@click.stop="emit('remove')"
v-if="src"
/>
<!-- File Input -->
<input
ref="fileInput"
type="file"
class="hidden"
accept="image/png,image/jpeg,image/jpg"
@change="handleChange"
/>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { X } from 'lucide-vue-next'
defineProps({
src: String,
initials: String,
label: {
type: String,
default: 'Upload'
}
})
const emit = defineEmits(['upload', 'remove'])
const fileInput = ref(null)
function triggerFileInput() {
fileInput.value?.click()
}
function handleChange(e) {
const file = e.target.files[0]
if (file) emit('upload', file)
}
</script>

View File

@@ -3,6 +3,7 @@ import { cva } from 'class-variance-authority'
export { default as Avatar } from './Avatar.vue'
export { default as AvatarImage } from './AvatarImage.vue'
export { default as AvatarFallback } from './AvatarFallback.vue'
export { default as AvatarUpload } from './AvatarUpload.vue'
export const avatarVariant = cva(
'inline-flex items-center justify-center font-normal text-foreground select-none shrink-0 bg-secondary overflow-hidden',

View File

@@ -1,25 +1,16 @@
<script setup>
import { Primitive } from 'radix-vue'
import { buttonVariants } from '.'
import { Primitive } from 'reka-ui'
import { cn } from '@/lib/utils'
import { ref, computed } from 'vue'
import { DotLoader } from '@/components/ui/loader'
import { buttonVariants } from '.'
import { Loader2 } from 'lucide-vue-next'
const props = defineProps({
variant: { type: null, required: false },
size: { type: null, required: false },
class: { type: null, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false, default: 'button' },
isLoading: { type: Boolean, required: false, default: false }
})
const isDisabled = ref(false)
const computedClass = computed(() => {
return cn(buttonVariants({ variant: props.variant, size: props.size }), props.class, {
'cursor-not-allowed opacity-50': props.isLoading || isDisabled.value
})
isLoading: { type: Boolean, required: false, default: false },
disabled: { type: Boolean, required: false, default: false }
})
</script>
@@ -27,10 +18,22 @@ const computedClass = computed(() => {
<Primitive
:as="as"
:as-child="asChild"
:class="computedClass"
:disabled="isLoading || isDisabled"
:class="
cn(
buttonVariants({ variant, size }),
'relative',
{ 'text-transparent': isLoading },
props.class
)
"
:disabled="isLoading || disabled"
>
<DotLoader v-if="isLoading" />
<slot v-else />
<slot />
<span
v-if="isLoading"
class="absolute inset-0 flex items-center justify-center pointer-events-none text-background"
>
<Loader2 class="h-5 w-5 animate-spin" />
</span>
</Primitive>
</template>

View File

@@ -1,31 +1,34 @@
import { cva } from 'class-variance-authority'
import { cva } from 'class-variance-authority';
export { default as Button } from './Button.vue'
export { default as Button } from './Button.vue';
export const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
default:
'bg-primary text-primary-foreground shadow hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
outline:
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
secondary:
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline'
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
xs: 'h-7 rounded px-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9'
}
icon: 'h-9 w-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default'
}
}
)
size: 'default',
},
},
);

View File

@@ -5,7 +5,7 @@
variant="outline"
role="combobox"
:aria-expanded="open"
class="w-full justify-between"
:class="['w-full justify-between', buttonClass]"
>
<slot name="selected" :selected="selectedItem">{{ selectedLabel }}</slot>
<CaretSortIcon class="ml-2 h-4 w-4 shrink-0 opacity-50" />
@@ -58,7 +58,11 @@ const props = defineProps({
required: true
},
placeholder: String,
defaultLabel: String
defaultLabel: String,
buttonClass: {
type: String,
default: ''
}
})
const emit = defineEmits(['select'])

View File

@@ -1,19 +1,19 @@
<script setup>
import { useVModel } from '@vueuse/core'
import { cn } from '@/lib/utils'
import { useVModel } from '@vueuse/core';
import { cn } from '@/lib/utils';
const props = defineProps({
defaultValue: { type: [String, Number], required: false },
modelValue: { type: [String, Number], required: false },
class: { type: null, required: false }
})
class: { type: null, required: false },
});
const emits = defineEmits(['update:modelValue'])
const emits = defineEmits(['update:modelValue']);
const modelValue = useVModel(props, 'modelValue', emits, {
passive: true,
defaultValue: props.defaultValue
})
defaultValue: props.defaultValue,
});
</script>
<template>
@@ -22,7 +22,7 @@ const modelValue = useVModel(props, 'modelValue', emits, {
:class="
cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
props.class
props.class,
)
"
/>

View File

@@ -1 +1 @@
export { default as Input } from './Input.vue'
export { default as Input } from './Input.vue';

View File

@@ -1,7 +1,11 @@
<template>
<span class="dot-loader">
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
<span class="inline-flex items-center">
<span class="w-1 h-1 rounded-full bg-current mx-0.5 animate-dot-flashing"></span>
<span
class="w-1 h-1 rounded-full bg-current mx-0.5 animate-dot-flashing [animation-delay:0.2s]"
></span>
<span
class="w-1 h-1 rounded-full bg-current mx-0.5 animate-dot-flashing [animation-delay:0.4s]"
></span>
</span>
</template>

View File

@@ -0,0 +1,29 @@
<script setup>
import { cn } from '@/lib/utils';
import { DotsHorizontalIcon } from '@radix-icons/vue';
import { PaginationEllipsis } from 'reka-ui';
import { computed } from 'vue';
const props = defineProps({
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>
<PaginationEllipsis
v-bind="delegatedProps"
:class="cn('w-9 h-9 flex items-center justify-center', props.class)"
>
<slot>
<DotsHorizontalIcon />
</slot>
</PaginationEllipsis>
</template>

View File

@@ -0,0 +1,29 @@
<script setup>
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { ChevronsLeft } from 'lucide-vue-next';
import { PaginationFirst } from 'reka-ui';
import { computed } from 'vue';
const props = defineProps({
asChild: { type: Boolean, required: false, default: true },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
</script>
<template>
<PaginationFirst v-bind="delegatedProps">
<Button :class="cn('w-9 h-9 p-0', props.class)" variant="outline">
<slot>
<ChevronsLeft />
</slot>
</Button>
</PaginationFirst>
</template>

View File

@@ -0,0 +1,29 @@
<script setup>
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { ChevronsRight } from 'lucide-vue-next';
import { PaginationLast } from 'reka-ui';
import { computed } from 'vue';
const props = defineProps({
asChild: { type: Boolean, required: false, default: true },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
</script>
<template>
<PaginationLast v-bind="delegatedProps">
<Button :class="cn('w-9 h-9 p-0', props.class)" variant="outline">
<slot>
<ChevronsRight />
</slot>
</Button>
</PaginationLast>
</template>

View File

@@ -0,0 +1,29 @@
<script setup>
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { ChevronRightIcon } from '@radix-icons/vue';
import { PaginationNext } from 'reka-ui';
import { computed } from 'vue';
const props = defineProps({
asChild: { type: Boolean, required: false, default: true },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
</script>
<template>
<PaginationNext v-bind="delegatedProps">
<Button :class="cn('w-9 h-9 p-0', props.class)" variant="outline">
<slot>
<ChevronRightIcon />
</slot>
</Button>
</PaginationNext>
</template>

View File

@@ -0,0 +1,29 @@
<script setup>
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { ChevronLeftIcon } from '@radix-icons/vue';
import { PaginationPrev } from 'reka-ui';
import { computed } from 'vue';
const props = defineProps({
asChild: { type: Boolean, required: false, default: true },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
</script>
<template>
<PaginationPrev v-bind="delegatedProps">
<Button :class="cn('w-9 h-9 p-0', props.class)" variant="outline">
<slot>
<ChevronLeftIcon />
</slot>
</Button>
</PaginationPrev>
</template>

View File

@@ -0,0 +1,10 @@
export { default as PaginationEllipsis } from './PaginationEllipsis.vue';
export { default as PaginationFirst } from './PaginationFirst.vue';
export { default as PaginationLast } from './PaginationLast.vue';
export { default as PaginationNext } from './PaginationNext.vue';
export { default as PaginationPrev } from './PaginationPrev.vue';
export {
PaginationRoot as Pagination,
PaginationList,
PaginationListItem,
} from 'reka-ui';

View File

@@ -4,8 +4,8 @@ import { SelectRoot, useForwardPropsEmits } from 'radix-vue'
const props = defineProps({
open: { type: Boolean, required: false },
defaultOpen: { type: Boolean, required: false },
defaultValue: { type: String, required: false },
modelValue: { type: String, required: false },
defaultValue: { type: [String, Number], required: false },
modelValue: { type: [String, Number], required: false },
dir: { type: String, required: false },
name: { type: String, required: false },
autocomplete: { type: String, required: false },

View File

@@ -2,7 +2,7 @@
import { SelectValue } from 'radix-vue'
const props = defineProps({
placeholder: { type: String, required: false },
placeholder: { type: [String, Number], required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false }
})

View File

@@ -1,21 +1,17 @@
<script setup>
import { computed } from 'vue'
import { Separator } from 'radix-vue'
import { cn } from '@/lib/utils'
import { reactiveOmit } from '@vueuse/core';
import { Separator } from 'reka-ui';
import { cn } from '@/lib/utils';
const props = defineProps({
orientation: { type: String, required: false },
decorative: { type: Boolean, required: false },
orientation: { type: String, required: false, default: 'horizontal' },
decorative: { type: Boolean, required: false, default: true },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false }
})
class: { type: null, required: false },
});
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const delegatedProps = reactiveOmit(props, 'class');
</script>
<template>
@@ -24,8 +20,8 @@ const delegatedProps = computed(() => {
:class="
cn(
'shrink-0 bg-border',
props.orientation === 'vertical' ? 'w-px h-full' : 'h-px w-full',
props.class
props.orientation === 'horizontal' ? 'h-px w-full' : 'w-px h-full',
props.class,
)
"
/>

View File

@@ -1 +1 @@
export { default as Separator } from './Separator.vue'
export { default as Separator } from './Separator.vue';

View File

@@ -1,14 +1,14 @@
<script setup>
import { DialogRoot, useForwardPropsEmits } from 'radix-vue'
import { DialogRoot, useForwardPropsEmits } from 'reka-ui';
const props = defineProps({
open: { type: Boolean, required: false },
defaultOpen: { type: Boolean, required: false },
modal: { type: Boolean, required: false }
})
const emits = defineEmits(['update:open'])
modal: { type: Boolean, required: false },
});
const emits = defineEmits(['update:open']);
const forwarded = useForwardPropsEmits(props, emits)
const forwarded = useForwardPropsEmits(props, emits);
</script>
<template>

View File

@@ -1,10 +1,10 @@
<script setup>
import { DialogClose } from 'radix-vue'
import { DialogClose } from 'reka-ui';
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false }
})
as: { type: null, required: false },
});
</script>
<template>

View File

@@ -1,19 +1,19 @@
<script setup>
import { computed } from 'vue'
import { reactiveOmit } from '@vueuse/core';
import { Cross2Icon } from '@radix-icons/vue';
import {
DialogClose,
DialogContent,
DialogOverlay,
DialogPortal,
useForwardPropsEmits
} from 'radix-vue'
import { Cross2Icon } from '@radix-icons/vue'
import { sheetVariants } from '.'
import { cn } from '@/lib/utils'
useForwardPropsEmits,
} from 'reka-ui';
import { cn } from '@/lib/utils';
import { sheetVariants } from '.';
defineOptions({
inheritAttrs: false
})
inheritAttrs: false,
});
const props = defineProps({
class: { type: null, required: false },
@@ -22,8 +22,8 @@ const props = defineProps({
trapFocus: { type: Boolean, required: false },
disableOutsidePointerEvents: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false }
})
as: { type: null, required: false },
});
const emits = defineEmits([
'escapeKeyDown',
@@ -31,16 +31,12 @@ const emits = defineEmits([
'focusOutside',
'interactOutside',
'openAutoFocus',
'closeAutoFocus'
])
'closeAutoFocus',
]);
const delegatedProps = computed(() => {
const { class: _, side, ...delegated } = props
const delegatedProps = reactiveOmit(props, 'class', 'side');
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>

View File

@@ -1,19 +1,15 @@
<script setup>
import { computed } from 'vue'
import { DialogDescription } from 'radix-vue'
import { cn } from '@/lib/utils'
import { reactiveOmit } from '@vueuse/core';
import { DialogDescription } from 'reka-ui';
import { cn } from '@/lib/utils';
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false }
})
class: { type: null, required: false },
});
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const delegatedProps = reactiveOmit(props, 'class');
</script>
<template>

View File

@@ -1,13 +1,20 @@
<script setup>
import { cn } from '@/lib/utils'
import { cn } from '@/lib/utils';
const props = defineProps({
class: { type: null, required: false }
})
class: { type: null, required: false },
});
</script>
<template>
<div :class="cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2', props.class)">
<div
:class="
cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2',
props.class,
)
"
>
<slot />
</div>
</template>

View File

@@ -1,13 +1,15 @@
<script setup>
import { cn } from '@/lib/utils'
import { cn } from '@/lib/utils';
const props = defineProps({
class: { type: null, required: false }
})
class: { type: null, required: false },
});
</script>
<template>
<div :class="cn('flex flex-col gap-y-2 text-center sm:text-left', props.class)">
<div
:class="cn('flex flex-col gap-y-2 text-center sm:text-left', props.class)"
>
<slot />
</div>
</template>

View File

@@ -1,19 +1,15 @@
<script setup>
import { computed } from 'vue'
import { DialogTitle } from 'radix-vue'
import { cn } from '@/lib/utils'
import { reactiveOmit } from '@vueuse/core';
import { DialogTitle } from 'reka-ui';
import { cn } from '@/lib/utils';
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false }
})
class: { type: null, required: false },
});
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const delegatedProps = reactiveOmit(props, 'class');
</script>
<template>

View File

@@ -1,10 +1,10 @@
<script setup>
import { DialogTrigger } from 'radix-vue'
import { DialogTrigger } from 'reka-ui';
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false }
})
as: { type: null, required: false },
});
</script>
<template>

View File

@@ -1,13 +1,13 @@
import { cva } from 'class-variance-authority'
import { cva } from 'class-variance-authority';
export { default as Sheet } from './Sheet.vue'
export { default as SheetTrigger } from './SheetTrigger.vue'
export { default as SheetClose } from './SheetClose.vue'
export { default as SheetContent } from './SheetContent.vue'
export { default as SheetHeader } from './SheetHeader.vue'
export { default as SheetTitle } from './SheetTitle.vue'
export { default as SheetDescription } from './SheetDescription.vue'
export { default as SheetFooter } from './SheetFooter.vue'
export { default as Sheet } from './Sheet.vue';
export { default as SheetClose } from './SheetClose.vue';
export { default as SheetContent } from './SheetContent.vue';
export { default as SheetDescription } from './SheetDescription.vue';
export { default as SheetFooter } from './SheetFooter.vue';
export { default as SheetHeader } from './SheetHeader.vue';
export { default as SheetTitle } from './SheetTitle.vue';
export { default as SheetTrigger } from './SheetTrigger.vue';
export const sheetVariants = cva(
'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
@@ -19,11 +19,11 @@ export const sheetVariants = cva(
'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
right:
'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm'
}
'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
},
},
defaultVariants: {
side: 'right'
}
}
)
side: 'right',
},
},
);

View File

@@ -1,6 +1,6 @@
<script setup>
import { Sheet, SheetContent } from '@/components/ui/sheet';
import { cn } from '@/lib/utils';
import { Sheet, SheetContent } from '@/components/ui/sheet';
import { SIDEBAR_WIDTH_MOBILE, useSidebar } from './utils';
defineOptions({
@@ -12,7 +12,6 @@ const props = defineProps({
variant: { type: String, required: false, default: 'sidebar' },
collapsible: { type: String, required: false, default: 'offcanvas' },
class: { type: null, required: false },
collapseOnMobile: { type: Boolean, required: false, default: true },
});
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
@@ -33,7 +32,7 @@ const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
</div>
<Sheet
v-else-if="isMobile && collapseOnMobile"
v-else-if="isMobile"
:open="openMobile"
v-bind="$attrs"
@update:open="setOpenMobile"
@@ -55,7 +54,7 @@ const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
<div
v-else
:class="cn('group peer', collapseOnMobile ? 'hidden md:block' : 'block')"
class="group peer hidden md:block"
:data-state="state"
:data-collapsible="state === 'collapsed' ? collapsible : ''"
:data-variant="variant"
@@ -77,8 +76,7 @@ const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
<div
:class="
cn(
'duration-200 fixed inset-y-0 z-10 h-svh w-[--sidebar-width] transition-[left,right,width] ease-linear md:flex',
collapseOnMobile ? 'hidden' : '',
'duration-200 fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] ease-linear md:flex',
side === 'left'
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',

View File

@@ -1,12 +1,12 @@
<script setup lang="ts">
import type { PrimitiveProps } from 'radix-vue'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { Primitive } from 'radix-vue'
<script setup>
import { Primitive } from 'reka-ui';
import { cn } from '@/lib/utils';
const props = defineProps<PrimitiveProps & {
class?: HTMLAttributes['class']
}>()
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
</script>
<template>
@@ -14,12 +14,14 @@ const props = defineProps<PrimitiveProps & {
data-sidebar="group-action"
:as="as"
:as-child="asChild"
:class="cn(
'absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
'after:absolute after:-inset-2 after:md:hidden',
'group-data-[collapsible=icon]:hidden',
props.class,
)"
:class="
cn(
'absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
'after:absolute after:-inset-2 after:md:hidden',
'group-data-[collapsible=icon]:hidden',
props.class,
)
"
>
<slot />
</Primitive>

View File

@@ -1,17 +1,13 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
<script setup>
import { cn } from '@/lib/utils';
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-sidebar="group-content"
:class="cn('w-full text-sm', props.class)"
>
<div data-sidebar="group-content" :class="cn('w-full text-sm', props.class)">
<slot />
</div>
</template>

View File

@@ -1,12 +1,12 @@
<script setup lang="ts">
import type { PrimitiveProps } from 'radix-vue'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { Primitive } from 'radix-vue'
<script setup>
import { Primitive } from 'reka-ui';
import { cn } from '@/lib/utils';
const props = defineProps<PrimitiveProps & {
class?: HTMLAttributes['class']
}>()
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
</script>
<template>
@@ -14,10 +14,13 @@ const props = defineProps<PrimitiveProps & {
data-sidebar="group-label"
:as="as"
:as-child="asChild"
:class="cn(
'duration-200 flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',
props.class)"
:class="
cn(
'duration-200 flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',
props.class,
)
"
>
<slot />
</Primitive>

View File

@@ -1,10 +1,9 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
<script setup>
import { cn } from '@/lib/utils';
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>

View File

@@ -1,20 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
<script setup>
import { cn } from '@/lib/utils';
import { Input } from '@/components/ui/input';
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<Input
data-sidebar="input"
:class="cn(
'h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring',
props.class,
)"
:class="
cn(
'h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring',
props.class,
)
"
>
<slot />
</Input>

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