feat: adds dropdown to automation form fields

- feat: adds csrf token check
- feat: adds conversation sub and unsub for WS updates.
- Clean up and remove unncessary code
- refactor and simplify auth middlewares
- fix: automation rules
- Update schema.sql
This commit is contained in:
Abhinav Raut
2024-10-14 01:50:08 +05:30
parent fbf631d8ad
commit 98df9efd63
42 changed files with 830 additions and 470 deletions

View File

@@ -131,6 +131,8 @@ func handleUpdateConversationUserAssignee(r *fastglue.Request) error {
if err := app.conversation.UpdateConversationUserAssignee(uuid, assigneeID, user); err != nil {
return sendErrorEnvelope(r, err)
}
app.automation.EvaluateConversationUpdateRules(uuid)
return r.SendEnvelope(true)
}
@@ -152,6 +154,8 @@ func handleUpdateTeamAssignee(r *fastglue.Request) error {
if err := app.conversation.UpdateConversationTeamAssignee(uuid, assigneeID, user); err != nil {
return sendErrorEnvelope(r, err)
}
app.automation.EvaluateConversationUpdateRules(uuid)
return r.SendEnvelope(true)
}
@@ -171,6 +175,8 @@ func handleUpdateConversationPriority(r *fastglue.Request) error {
if err := app.conversation.UpdateConversationPriority(uuid, priority, user); err != nil {
return sendErrorEnvelope(r, err)
}
app.automation.EvaluateConversationUpdateRules(uuid)
return r.SendEnvelope(true)
}
@@ -190,6 +196,8 @@ func handleUpdateConversationStatus(r *fastglue.Request) error {
if err := app.conversation.UpdateConversationStatus(uuid, status, user); err != nil {
return sendErrorEnvelope(r, err)
}
app.automation.EvaluateConversationUpdateRules(uuid)
return r.SendEnvelope(true)
}
@@ -219,10 +227,10 @@ func handleAddConversationTags(r *fastglue.Request) error {
if err := app.conversation.UpsertConversationTags(uuid, tagIDs); err != nil {
return sendErrorEnvelope(r, err)
}
app.automation.EvaluateConversationUpdateRules(uuid)
return r.SendEnvelope(true)
}
// handleDashboardCounts retrieves general dashboard counts for all users.
func handleDashboardCounts(r *fastglue.Request) error {
var (

View File

@@ -15,136 +15,134 @@ import (
func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
// Authentication.
g.POST("/api/login", handleLogin)
g.GET("/api/logout", sess(authSess((handleLogout))))
g.GET("/api/logout", authMiddleware(handleLogout, "", ""))
g.GET("/api/oidc/{id}/login", handleOIDCLogin)
g.GET("/api/oidc/finish", handleOIDCCallback)
// Health check.
g.GET("/health", handleHealthCheck)
// Serve uploaded files.
g.GET("/uploads/{uuid}", sess(authSess(handleServeUploadedFiles)))
// Serve media files.
g.GET("/uploads/{uuid}", authMiddleware(handleServeMedia, "", ""))
// Settings.
g.GET("/api/settings/general", handleGetGeneralSettings)
g.PUT("/api/settings/general", perm(handleUpdateGeneralSettings, "settings_general", "write"))
g.GET("/api/settings/notifications/email", perm(handleGetEmailNotificationSettings, "settings_notifications", "read"))
g.PUT("/api/settings/notifications/email", perm(handleUpdateEmailNotificationSettings, "settings_notifications", "write"))
g.PUT("/api/settings/general", authMiddleware(handleUpdateGeneralSettings, "settings_general", "write"))
g.GET("/api/settings/notifications/email", authMiddleware(handleGetEmailNotificationSettings, "settings_notifications", "read"))
g.PUT("/api/settings/notifications/email", authMiddleware(handleUpdateEmailNotificationSettings, "settings_notifications", "write"))
// OpenID SSO.
g.GET("/api/oidc", handleGetAllOIDC)
g.GET("/api/oidc/{id}", perm(handleGetOIDC, "oidc", "read"))
g.POST("/api/oidc", perm(handleCreateOIDC, "oidc", "write"))
g.PUT("/api/oidc/{id}", perm(handleUpdateOIDC, "oidc", "write"))
g.DELETE("/api/oidc/{id}", perm(handleDeleteOIDC, "oidc", "delete"))
g.GET("/api/oidc/{id}", authMiddleware(handleGetOIDC, "oidc", "read"))
g.POST("/api/oidc", authMiddleware(handleCreateOIDC, "oidc", "write"))
g.PUT("/api/oidc/{id}", authMiddleware(handleUpdateOIDC, "oidc", "write"))
g.DELETE("/api/oidc/{id}", authMiddleware(handleDeleteOIDC, "oidc", "delete"))
// Conversation and message.
g.GET("/api/conversations/all", perm(handleGetAllConversations, "conversations", "read_all"))
g.GET("/api/conversations/unassigned", perm(handleGetUnassignedConversations, "conversations", "read_unassigned"))
g.GET("/api/conversations/assigned", perm(handleGetAssignedConversations, "conversations", "read_assigned"))
g.PUT("/api/conversations/{uuid}/assignee/user", perm(handleUpdateConversationUserAssignee, "conversations", "update_user_assignee"))
g.PUT("/api/conversations/{uuid}/assignee/team", perm(handleUpdateTeamAssignee, "conversations", "update_team_assignee"))
g.PUT("/api/conversations/{uuid}/priority", perm(handleUpdateConversationPriority, "conversations", "update_priority"))
g.PUT("/api/conversations/{uuid}/status", perm(handleUpdateConversationStatus, "conversations", "update_status"))
g.GET("/api/conversations/{uuid}", perm(handleGetConversation, "conversations", "read"))
g.GET("/api/conversations/{uuid}/participants", perm(handleGetConversationParticipants, "conversations", "read"))
g.PUT("/api/conversations/{uuid}/last-seen", perm(handleUpdateConversationAssigneeLastSeen, "conversations", "read"))
g.POST("/api/conversations/{uuid}/tags", perm(handleAddConversationTags, "conversations", "update_tags"))
g.GET("/api/conversations/{uuid}/messages", perm(handleGetMessages, "messages", "read"))
g.POST("/api/conversations/{cuuid}/messages", perm(handleSendMessage, "messages", "write"))
g.PUT("/api/conversations/{cuuid}/messages/{uuid}/retry", perm(handleRetryMessage, "messages", "write"))
g.GET("/api/conversations/{cuuid}/messages/{uuid}", perm(handleGetMessage, "messages", "read"))
g.GET("/api/conversations/all", authMiddleware(handleGetAllConversations, "conversations", "read_all"))
g.GET("/api/conversations/unassigned", authMiddleware(handleGetUnassignedConversations, "conversations", "read_unassigned"))
g.GET("/api/conversations/assigned", authMiddleware(handleGetAssignedConversations, "conversations", "read_assigned"))
g.GET("/api/conversations/{uuid}", authMiddleware(handleGetConversation, "conversations", "read"))
g.GET("/api/conversations/{uuid}/participants", authMiddleware(handleGetConversationParticipants, "conversations", "read"))
g.PUT("/api/conversations/{uuid}/assignee/user", authMiddleware(handleUpdateConversationUserAssignee, "conversations", "update_user_assignee"))
g.PUT("/api/conversations/{uuid}/assignee/team", authMiddleware(handleUpdateTeamAssignee, "conversations", "update_team_assignee"))
g.PUT("/api/conversations/{uuid}/priority", authMiddleware(handleUpdateConversationPriority, "conversations", "update_priority"))
g.PUT("/api/conversations/{uuid}/status", authMiddleware(handleUpdateConversationStatus, "conversations", "update_status"))
g.PUT("/api/conversations/{uuid}/last-seen", authMiddleware(handleUpdateConversationAssigneeLastSeen, "conversations", "read"))
g.POST("/api/conversations/{uuid}/tags", authMiddleware(handleAddConversationTags, "conversations", "update_tags"))
g.POST("/api/conversations/{cuuid}/messages", authMiddleware(handleSendMessage, "messages", "write"))
g.GET("/api/conversations/{uuid}/messages", authMiddleware(handleGetMessages, "messages", "read"))
g.PUT("/api/conversations/{cuuid}/messages/{uuid}/retry", authMiddleware(handleRetryMessage, "messages", "write"))
g.GET("/api/conversations/{cuuid}/messages/{uuid}", authMiddleware(handleGetMessage, "messages", "read"))
// Status and priority.
g.GET("/api/statuses", sess(authSess(handleGetStatuses)))
g.POST("/api/statuses", perm(handleCreateStatus, "status", "write"))
g.PUT("/api/statuses/{id}", perm(handleUpdateStatus, "status", "write"))
g.DELETE("/api/statuses/{id}", perm(handleDeleteStatus, "status", "delete"))
g.GET("/api/priorities", sess(authSess(handleGetPriorities)))
g.GET("/api/statuses", authMiddleware(handleGetStatuses, "", ""))
g.POST("/api/statuses", authMiddleware(handleCreateStatus, "status", "write"))
g.PUT("/api/statuses/{id}", authMiddleware(handleUpdateStatus, "status", "write"))
g.DELETE("/api/statuses/{id}", authMiddleware(handleDeleteStatus, "status", "delete"))
g.GET("/api/priorities", authMiddleware(handleGetPriorities, "", ""))
// Tag.
g.GET("/api/tags", sess(authSess(handleGetTags)))
g.POST("/api/tags", perm(handleCreateTag, "tags", "write"))
g.PUT("/api/tags/{id}", perm(handleUpdateTag, "tags", "write"))
g.DELETE("/api/tags/{id}", perm(handleDeleteTag, "tags", "delete"))
g.GET("/api/tags", authMiddleware(handleGetTags, "", ""))
g.POST("/api/tags", authMiddleware(handleCreateTag, "tags", "write"))
g.PUT("/api/tags/{id}", authMiddleware(handleUpdateTag, "tags", "write"))
g.DELETE("/api/tags/{id}", authMiddleware(handleDeleteTag, "tags", "delete"))
// Media.
g.POST("/api/media", sess(handleMediaUpload))
g.POST("/api/media", authMiddleware(handleMediaUpload, "", ""))
// Canned response.
g.GET("/api/canned-responses", sess(authSess(handleGetCannedResponses)))
g.POST("/api/canned-responses", perm(handleCreateCannedResponse, "canned_responses", "write"))
g.PUT("/api/canned-responses/{id}", perm(handleUpdateCannedResponse, "canned_responses", "write"))
g.DELETE("/api/canned-responses/{id}", perm(handleDeleteCannedResponse, "canned_responses", "delete"))
g.GET("/api/canned-responses", authMiddleware(handleGetCannedResponses, "", ""))
g.POST("/api/canned-responses", authMiddleware(handleCreateCannedResponse, "canned_responses", "write"))
g.PUT("/api/canned-responses/{id}", authMiddleware(handleUpdateCannedResponse, "canned_responses", "write"))
g.DELETE("/api/canned-responses/{id}", authMiddleware(handleDeleteCannedResponse, "canned_responses", "delete"))
// User.
g.GET("/api/users/me", sess(authSess(handleGetCurrentUser)))
g.PUT("/api/users/me", sess(authSess(handleUpdateCurrentUser)))
g.DELETE("/api/users/me/avatar", sess(authSess(handleDeleteAvatar)))
g.GET("/api/users/compact", sess(authSess(handleGetUsersCompact)))
g.GET("/api/users", perm(handleGetUsers, "users", "read"))
g.GET("/api/users/{id}", perm(handleGetUser, "users", "read"))
g.POST("/api/users", perm(handleCreateUser, "users", "write"))
g.PUT("/api/users/{id}", perm(handleUpdateUser, "users", "write"))
g.GET("/api/users/me", authMiddleware(handleGetCurrentUser, "", ""))
g.PUT("/api/users/me", authMiddleware(handleUpdateCurrentUser, "", ""))
g.DELETE("/api/users/me/avatar", authMiddleware(handleDeleteAvatar, "", ""))
g.GET("/api/users/compact", authMiddleware(handleGetUsersCompact, "", ""))
g.GET("/api/users", authMiddleware(handleGetUsers, "users", "read"))
g.GET("/api/users/{id}", authMiddleware(handleGetUser, "users", "read"))
g.POST("/api/users", authMiddleware(handleCreateUser, "users", "write"))
g.PUT("/api/users/{id}", authMiddleware(handleUpdateUser, "users", "write"))
// Team.
g.GET("/api/teams/compact", sess(authSess(handleGetTeamsCompact)))
g.GET("/api/teams", perm(handleGetTeams, "teams", "read"))
g.GET("/api/teams/{id}", perm(handleGetTeam, "teams", "read"))
g.PUT("/api/teams/{id}", perm(handleUpdateTeam, "teams", "write"))
g.POST("/api/teams", perm(handleCreateTeam, "teams", "write"))
g.GET("/api/teams/compact", authMiddleware(handleGetTeamsCompact, "", ""))
g.GET("/api/teams", authMiddleware(handleGetTeams, "teams", "read"))
g.GET("/api/teams/{id}", authMiddleware(handleGetTeam, "teams", "read"))
g.PUT("/api/teams/{id}", authMiddleware(handleUpdateTeam, "teams", "write"))
g.POST("/api/teams", authMiddleware(handleCreateTeam, "teams", "write"))
// i18n.
g.GET("/api/lang/{lang}", handleGetI18nLang)
// Automation.
g.GET("/api/automation/rules", perm(handleGetAutomationRules, "automations", "read"))
g.GET("/api/automation/rules/{id}", perm(handleGetAutomationRule, "automations", "read"))
g.POST("/api/automation/rules", perm(handleCreateAutomationRule, "automations", "write"))
g.PUT("/api/automation/rules/{id}/toggle", perm(handleToggleAutomationRule, "automations", "write"))
g.PUT("/api/automation/rules/{id}", perm(handleUpdateAutomationRule, "automations", "write"))
g.DELETE("/api/automation/rules/{id}", perm(handleDeleteAutomationRule, "automations", "delete"))
g.GET("/api/automation/rules", authMiddleware(handleGetAutomationRules, "automations", "read"))
g.GET("/api/automation/rules/{id}", authMiddleware(handleGetAutomationRule, "automations", "read"))
g.POST("/api/automation/rules", authMiddleware(handleCreateAutomationRule, "automations", "write"))
g.PUT("/api/automation/rules/{id}/toggle", authMiddleware(handleToggleAutomationRule, "automations", "write"))
g.PUT("/api/automation/rules/{id}", authMiddleware(handleUpdateAutomationRule, "automations", "write"))
g.DELETE("/api/automation/rules/{id}", authMiddleware(handleDeleteAutomationRule, "automations", "delete"))
// Inbox.
g.GET("/api/inboxes", perm(handleGetInboxes, "inboxes", "read"))
g.GET("/api/inboxes/{id}", perm(handleGetInbox, "inboxes", "read"))
g.POST("/api/inboxes", perm(handleCreateInbox, "inboxes", "write"))
g.PUT("/api/inboxes/{id}/toggle", perm(handleToggleInbox, "inboxes", "write"))
g.PUT("/api/inboxes/{id}", perm(handleUpdateInbox, "inboxes", "write"))
g.DELETE("/api/inboxes/{id}", perm(handleDeleteInbox, "inboxes", "delete"))
g.GET("/api/inboxes", authMiddleware(handleGetInboxes, "inboxes", "read"))
g.GET("/api/inboxes/{id}", authMiddleware(handleGetInbox, "inboxes", "read"))
g.POST("/api/inboxes", authMiddleware(handleCreateInbox, "inboxes", "write"))
g.PUT("/api/inboxes/{id}/toggle", authMiddleware(handleToggleInbox, "inboxes", "write"))
g.PUT("/api/inboxes/{id}", authMiddleware(handleUpdateInbox, "inboxes", "write"))
g.DELETE("/api/inboxes/{id}", authMiddleware(handleDeleteInbox, "inboxes", "delete"))
// Role.
g.GET("/api/roles", perm(handleGetRoles, "roles", "read"))
g.GET("/api/roles/{id}", perm(handleGetRole, "roles", "read"))
g.POST("/api/roles", perm(handleCreateRole, "roles", "write"))
g.PUT("/api/roles/{id}", perm(handleUpdateRole, "roles", "write"))
g.DELETE("/api/roles/{id}", perm(handleDeleteRole, "roles", "delete"))
g.GET("/api/roles", authMiddleware(handleGetRoles, "roles", "read"))
g.GET("/api/roles/{id}", authMiddleware(handleGetRole, "roles", "read"))
g.POST("/api/roles", authMiddleware(handleCreateRole, "roles", "write"))
g.PUT("/api/roles/{id}", authMiddleware(handleUpdateRole, "roles", "write"))
g.DELETE("/api/roles/{id}", authMiddleware(handleDeleteRole, "roles", "delete"))
// Dashboard.
g.GET("/api/dashboard/global/counts", perm(handleDashboardCounts, "dashboard_global", "read"))
g.GET("/api/dashboard/global/charts", perm(handleDashboardCharts, "dashboard_global", "read"))
g.GET("/api/dashboard/global/counts", authMiddleware(handleDashboardCounts, "dashboard_global", "read"))
g.GET("/api/dashboard/global/charts", authMiddleware(handleDashboardCharts, "dashboard_global", "read"))
// Template.
g.GET("/api/templates", perm(handleGetTemplates, "templates", "read"))
g.GET("/api/templates/{id}", perm(handleGetTemplate, "templates", "read"))
g.POST("/api/templates", perm(handleCreateTemplate, "templates", "write"))
g.PUT("/api/templates/{id}", perm(handleUpdateTemplate, "templates", "write"))
g.DELETE("/api/templates/{id}", perm(handleDeleteTemplate, "templates", "delete"))
g.GET("/api/templates", authMiddleware(handleGetTemplates, "templates", "read"))
g.GET("/api/templates/{id}", authMiddleware(handleGetTemplate, "templates", "read"))
g.POST("/api/templates", authMiddleware(handleCreateTemplate, "templates", "write"))
g.PUT("/api/templates/{id}", authMiddleware(handleUpdateTemplate, "templates", "write"))
g.DELETE("/api/templates/{id}", authMiddleware(handleDeleteTemplate, "templates", "delete"))
// WebSocket.
g.GET("/api/ws", sess(authSess(func(r *fastglue.Request) error {
g.GET("/api/ws", authMiddleware(func(r *fastglue.Request) error {
return handleWS(r, hub)
})))
}, "", ""))
// Frontend pages.
g.GET("/", sess(noAuthPage(serveIndexPage)))
g.GET("/dashboard", sess(authPage(serveIndexPage)))
g.GET("/conversations", sess(authPage(serveIndexPage)))
g.GET("/conversations/{all:*}", sess(authPage(serveIndexPage)))
g.GET("/account/profile", sess(authPage(serveIndexPage)))
g.GET("/admin/{all:*}", sess(authPage(serveIndexPage)))
g.GET("/", notAuthenticatedPage(serveIndexPage))
g.GET("/dashboard", authenticatedPage(serveIndexPage))
g.GET("/conversations", authenticatedPage(serveIndexPage))
g.GET("/conversations/{all:*}", authenticatedPage(serveIndexPage))
g.GET("/account/profile", authenticatedPage(serveIndexPage))
g.GET("/admin/{all:*}", authenticatedPage(serveIndexPage))
g.GET("/assets/{all:*}", serveStaticFiles)
}

View File

@@ -22,6 +22,7 @@ func handleLogin(r *fastglue.Request) error {
app.lo.Error("error saving session", "error", err)
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("user.errorAcquiringSession"), nil))
}
app.auth.SetCSRFCookie(r)
return r.SendEnvelope(user)
}

View File

@@ -133,8 +133,8 @@ func handleMediaUpload(r *fastglue.Request) error {
return r.SendEnvelope(media)
}
// handleServeUploadedFiles serves uploaded files from the local filesystem or S3.
func handleServeUploadedFiles(r *fastglue.Request) error {
// handleServeMedia serves uploaded media.
func handleServeMedia(r *fastglue.Request) error {
var (
app = r.Context.(*App)
user = r.RequestCtx.UserValue("user").(umodels.User)
@@ -148,28 +148,28 @@ func handleServeUploadedFiles(r *fastglue.Request) error {
}
// Check if the user has permission to access the linked model.
// TODO: Move this out of here.
if media.Model.String == "messages" {
allowed, err := app.authz.Enforce(user, media.Model.String, "read")
if err != nil {
return r.SendErrorEnvelope(http.StatusInternalServerError, "Error checking permissions", nil, envelope.GeneralError)
}
if !allowed {
return r.SendErrorEnvelope(http.StatusUnauthorized, "Permission denied", nil, envelope.PermissionError)
}
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)
}
// Validate access to the related conversation.
// For messages, check access to the conversation this message is part of.
if media.Model.String == "messages" {
conversation, err := app.conversation.GetConversationByMessageID(media.ModelID.Int)
if err != nil {
return sendErrorEnvelope(r, err)
}
_, err = enforceConversationAccess(app, conversation.UUID, user)
allowed, err = app.authz.EnforceConversationAccess(user, conversation)
if err != nil {
return sendErrorEnvelope(r, err)
}
}
if !allowed {
return r.SendErrorEnvelope(http.StatusUnauthorized, "Permission denied", nil, envelope.PermissionError)
}
switch ko.String("upload.provider") {
case "fs":
fasthttp.ServeFile(r.RequestCtx, filepath.Join(ko.String("upload.fs.upload_path"), uuid))

View File

@@ -9,44 +9,60 @@ import (
"github.com/zerodha/fastglue"
)
func perm(handler fastglue.FastRequestHandler, obj, act string) fastglue.FastRequestHandler {
// authMiddleware does session validation, CSRF checking, and permission enforcement.
func authMiddleware(handler fastglue.FastRequestHandler, object, action string) fastglue.FastRequestHandler {
return func(r *fastglue.Request) error {
var app = r.Context.(*App)
app := r.Context.(*App)
user, err := app.auth.ValidateSession(r)
// Validate session and fetch user.
userSession, err := app.auth.ValidateSession(r)
if err != nil {
app.lo.Error("error validating session", "error", err)
return r.SendErrorEnvelope(http.StatusUnauthorized, "Invalid or expired session", nil, envelope.PermissionError)
}
// Fetch user and permissions from DB.
user, err = app.user.Get(user.ID)
user, err := app.user.Get(userSession.ID)
if err != nil {
return r.SendErrorEnvelope(http.StatusInternalServerError, "Something went wrong", nil, envelope.GeneralError)
}
// CSRF check.
cookieToken := string(r.RequestCtx.Request.Header.Cookie("csrf_token"))
hdrToken := string(r.RequestCtx.Request.Header.Peek("X-CSRFTOKEN"))
if cookieToken == "" || hdrToken == "" || cookieToken != hdrToken {
return r.SendErrorEnvelope(http.StatusForbidden, "Invalid CSRF token", nil, envelope.PermissionError)
}
// Permission enforcement.
if object != "" && action != "" {
ok, err := app.authz.Enforce(user, object, action)
if err != nil {
return r.SendErrorEnvelope(http.StatusInternalServerError, "Error checking permissions", nil, envelope.GeneralError)
}
if !ok {
return r.SendErrorEnvelope(http.StatusForbidden, "Permission denied", nil, envelope.PermissionError)
}
}
// Set user in the request context.
r.RequestCtx.SetUserValue("user", user)
// Enforce the permissions with the user, object, and action.
ok, err := app.authz.Enforce(user, obj, act)
if err != nil {
return r.SendErrorEnvelope(http.StatusInternalServerError, "Something went wrong", nil, envelope.GeneralError)
}
if !ok {
return r.SendErrorEnvelope(http.StatusForbidden, "Permission denied", nil, envelope.PermissionError)
}
// Return handler.
// Proceed to the next handler.
return handler(r)
}
}
// authPage middleware makes sure user is logged in to access the page else redirects to login page.
func authPage(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
// getUserFromContext retrieves the authenticated user from the request context.
func getUserFromContext(r *fastglue.Request) (umodels.User, bool) {
user, ok := r.RequestCtx.UserValue("user").(umodels.User)
return user, ok
}
// authenticatedPage ensures the user is logged in; otherwise, redirects to the login page.
func authenticatedPage(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
return func(r *fastglue.Request) error {
// Check if user is logged in. If logged in return next handler.
userID, ok := getAuthUserFromSess(r)
if ok && userID > 0 {
user, ok := getUserFromContext(r)
if ok && user.ID > 0 {
return handler(r)
}
nextURI := r.RequestCtx.QueryArgs().Peek("next")
@@ -59,54 +75,17 @@ func authPage(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
}
}
// getAuthUserFromSess retrives authUser from request context set by the sess() middleware.
func getAuthUserFromSess(r *fastglue.Request) (int, bool) {
user, ok := r.RequestCtx.UserValue("user").(umodels.User)
if user.ID == 0 || !ok {
return user.ID, false
}
return user.ID, true
}
func sess(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
// notAuthenticatedPage allows access only if the user is not authenticated; otherwise, redirects to the dashboard.
func notAuthenticatedPage(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
return func(r *fastglue.Request) error {
var app = r.Context.(*App)
user, err := app.auth.ValidateSession(r)
if err != nil {
app.lo.Error("error validating session", "error", err)
return r.SendErrorEnvelope(http.StatusUnauthorized, "Invalid or expired session", nil, envelope.PermissionError)
}
if user.ID >= 0 {
r.RequestCtx.SetUserValue("user", user)
user, _ := getUserFromContext(r)
if user.ID != 0 {
nextURI := string(r.RequestCtx.QueryArgs().Peek("next"))
if nextURI == "" {
nextURI = "/dashboard"
}
return r.RedirectURI(nextURI, fasthttp.StatusFound, nil, "")
}
return handler(r)
}
}
func authSess(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
return func(r *fastglue.Request) error {
var (
userID, ok = getAuthUserFromSess(r)
)
if !ok || userID <= 0 {
return sendErrorEnvelope(r,
envelope.NewError(envelope.GeneralError, "Invalid or expired session.", nil))
}
return handler(r)
}
}
func noAuthPage(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
return func(r *fastglue.Request) error {
_, ok := getAuthUserFromSess(r)
if !ok {
return handler(r)
}
// User is logged in direct if `next` is available else redirect.
nextURI := string(r.RequestCtx.QueryArgs().Peek("next"))
if len(nextURI) == 0 {
nextURI = "/dashboard"
}
return r.RedirectURI(nextURI, fasthttp.StatusFound, nil, "")
}
}

View File

@@ -75,7 +75,6 @@ const allNavLinks = ref([
const bottomLinks = ref([
{
to: '/api/logout',
isLink: false,
icon: 'lucide:log-out',
title: 'Logout'
}

View File

@@ -6,8 +6,27 @@ const http = axios.create({
responseType: 'json'
})
// Function to extract CSRF token from cookies
function getCSRFToken() {
const name = 'csrf_token=';
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
let c = cookies[i].trim();
if (c.indexOf(name) === 0) {
return c.substring(name.length, c.length);
}
}
return '';
}
// Request interceptor.
http.interceptors.request.use((request) => {
// Add csrf token
const token = getCSRFToken()
if (token) {
request.headers['X-CSRFTOKEN'] = token
}
// Set content type for POST/PUT requests if the content type is not set.
if ((request.method === 'post' || request.method === 'put') && !request.headers['Content-Type']) {
request.headers['Content-Type'] = 'application/x-www-form-urlencoded'

View File

@@ -6,38 +6,43 @@
<hr class="border-t-2 border-dotted border-gray-300" />
</div>
<div class="flex space-x-5 justify-between">
<Select
v-model="action.type"
@update:modelValue="(value) => handleFieldChange(value, index)"
>
<SelectTrigger class="w-56">
<SelectValue placeholder="Select action" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Conversation</SelectLabel>
<SelectItem
v-for="(actionItem, key) in conversationActions"
:key="key"
:value="key"
>
{{ actionItem.label }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<div class="flex space-x-5">
<!-- Field -->
<Select v-model="action.type" @update:modelValue="(value) => handleFieldChange(value, index)">
<SelectTrigger class="w-56">
<SelectValue placeholder="Select action" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Conversation</SelectLabel>
<SelectItem v-for="(actionItem, key) in conversationActions" :key="key" :value="key">
{{ actionItem.label }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<!-- Value -->
<Select v-model="action.value" @update:modelValue="(value) => handleValueChange(value, index)">
<SelectTrigger class="w-56">
<SelectValue placeholder="Select value" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="(act, index) in getDropdownValues(action.type).value" :key="index"
:value="act.value.toString()">
{{ act.name }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
<div class="cursor-pointer" @click.prevent="removeAction(index)">
<CircleX size="21" />
</div>
</div>
<div>
<Input
type="text"
placeholder="Set value"
:modelValue="action.value"
@update:modelValue="(value) => handleValueChange(value, index)"
/>
</div>
</div>
</div>
<div>
@@ -47,7 +52,7 @@
</template>
<script setup>
import { toRefs } from 'vue'
import { toRefs, ref, onMounted } from 'vue'
import { Button } from '@/components/ui/button'
import { CircleX } from 'lucide-vue-next'
import {
@@ -59,7 +64,10 @@ import {
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { Input } from '@/components/ui/input'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useEmitter } from '@/composables/useEmitter'
import { handleHTTPError } from '@/utils/http'
import api from '@/api'
const props = defineProps({
actions: {
@@ -69,10 +77,59 @@ const props = defineProps({
})
const { actions } = toRefs(props)
const emitter = useEmitter()
const teams = ref([])
const users = ref([])
const statuses = ref([])
const priorities = ref([
{
value: "Low",
name: "Low"
},
{
value: "Medium",
name: "Medium"
},
{
value: "High",
name: "High"
},
])
const emit = defineEmits(['update-actions', 'add-action', 'remove-action'])
onMounted(async () => {
try {
const [teamsResp, usersResp, statusesResp] = await Promise.all([
api.getTeamsCompact(),
api.getUsersCompact(),
api.getStatuses()
])
teams.value = teamsResp.data.data.map(team => ({
value: team.id,
name: team.name
}))
users.value = usersResp.data.data.map(user => ({
value: user.id,
name: user.first_name + ' ' + user.last_name
}))
statuses.value = statusesResp.data.data.map(status => ({
value: status.name,
name: status.name
}))
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Something went wrong',
variant: 'destructive',
description: handleHTTPError(error).message
})
}
})
const handleFieldChange = (value, index) => {
actions.value[index].value = ''
actions.value[index].type = value
emitUpdate(index)
}
@@ -108,4 +165,15 @@ const conversationActions = {
label: 'Set priority'
}
}
const actionDropdownValues = {
assign_team: teams,
assign_user: users,
set_status: statuses,
set_priority: priorities,
}
const getDropdownValues = (field) => {
return actionDropdownValues[field] || []
}
</script>

View File

@@ -1,8 +1,8 @@
<template>
<Tabs default-value="conversation_creation">
<TabsList class="grid w-full grid-cols-3 mb-5">
<TabsTrigger value="conversation_creation"> Conversation creation </TabsTrigger>
<TabsTrigger value="conversation_updates"> Conversation updates </TabsTrigger>
<TabsTrigger value="conversation_creation"> New conversation </TabsTrigger>
<TabsTrigger value="conversation_updates"> Conversation update </TabsTrigger>
<TabsTrigger value="time_triggers"> Time triggers </TabsTrigger>
</TabsList>
<TabsContent value="conversation_creation">

View File

@@ -128,7 +128,6 @@ const rule = ref({
type: 'new_conversation',
rules: [
{
type: 'new_conversation',
groups: [
{
rules: [],

View File

@@ -1,11 +1,7 @@
<template>
<div>
<div class="mb-5">
<RadioGroup
class="flex"
:modelValue="ruleGroup.logical_op"
@update:modelValue="handleGroupOperator"
>
<RadioGroup class="flex" :modelValue="ruleGroup.logical_op" @update:modelValue="handleGroupOperator">
<div class="flex items-center space-x-2">
<RadioGroupItem value="OR" />
<Label for="r1">Match <b>ANY</b> of below.</Label>
@@ -24,10 +20,8 @@
</div>
<div class="flex justify-between">
<div class="flex space-x-5">
<Select
v-model="rule.field"
@update:modelValue="(value) => handleFieldChange(value, index)"
>
<!-- Field selection -->
<Select v-model="rule.field" @update:modelValue="(value) => handleFieldChange(value, index)">
<SelectTrigger class="w-56">
<SelectValue placeholder="Select field" />
</SelectTrigger>
@@ -41,17 +35,15 @@
</SelectContent>
</Select>
<Select
v-model="rule.operator"
@update:modelValue="(value) => handleOperatorChange(value, index)"
>
<!-- Operator selection -->
<Select v-model="rule.operator" @update:modelValue="(value) => handleOperatorChange(value, index)">
<SelectTrigger class="w-56">
<SelectValue placeholder="Select operator" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="(field, key) in operators" :key="key" :value="key">
{{ field.label }}
<SelectItem v-for="(op, key) in getFieldOperators(rule.field)" :key="key" :value="op">
{{ op }}
</SelectItem>
</SelectGroup>
</SelectContent>
@@ -61,20 +53,32 @@
<CircleX size="21" />
</div>
</div>
<div>
<Input
type="text"
placeholder="Set value"
:modelValue="rule.value"
@update:modelValue="(value) => handleValueChange(value, index)"
/>
<!-- Value input based on field type -->
<div v-if="showInput(index)">
<!-- Text input -->
<Input type="text" placeholder="Set value" v-if="inputType(index) === 'text'" :modelValue="rule.value"
@update:modelValue="(value) => handleValueChange(value, index)" />
<!-- Dropdown -->
<Select v-model="rule.value" @update:modelValue="(value) => handleValueChange(value, index)"
v-if="inputType(index) === 'select'">
<SelectTrigger class="w-56">
<SelectValue placeholder="Select value" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="(op, key) in getFieldOptions(rule.field)" :key="key" :value="op">
{{ op }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
<div class="flex items-center space-x-2">
<Checkbox
id="terms"
:defaultChecked="rule.case_sensitive_match"
@update:checked="(value) => handleCaseSensitiveCheck(value, index)"
/>
<Checkbox id="terms" :defaultChecked="rule.case_sensitive_match"
@update:checked="(value) => handleCaseSensitiveCheck(value, index)" />
<label for="terms"> Case sensitive match </label>
</div>
</div>
@@ -87,7 +91,7 @@
</template>
<script setup>
import { toRefs } from 'vue'
import { toRefs, ref, onMounted } from 'vue'
import { Checkbox } from '@/components/ui/checkbox'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Button } from '@/components/ui/button'
@@ -103,6 +107,10 @@ import {
import { CircleX } from 'lucide-vue-next'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useEmitter } from '@/composables/useEmitter'
import { handleHTTPError } from '@/utils/http'
import api from '@/api'
const props = defineProps({
ruleGroup: {
@@ -110,13 +118,31 @@ const props = defineProps({
required: true
},
groupIndex: {
Type: Number,
type: Number,
required: true
}
})
const emitter = useEmitter()
const statuses = ref([])
const priorities = ref([
"Low", "Medium", "High"
])
const { ruleGroup } = toRefs(props)
onMounted(async () => {
try {
const [statusesResp] = await Promise.all([api.getStatuses()])
statuses.value = statusesResp.data.data.map(status => (status.name))
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Could not fetch statuses',
variant: 'destructive',
description: handleHTTPError(error).message
})
}
})
const emit = defineEmits(['update-group', 'add-condition', 'remove-condition'])
const handleGroupOperator = (value) => {
@@ -125,11 +151,16 @@ const handleGroupOperator = (value) => {
}
const handleFieldChange = (value, ruleIndex) => {
// Clear operator and value on field change
ruleGroup.value.rules[ruleIndex].operator = ''
ruleGroup.value.rules[ruleIndex].value = ''
ruleGroup.value.rules[ruleIndex].field = value
emitUpdate()
}
const handleOperatorChange = (value, ruleIndex) => {
// Clear value on operator change
ruleGroup.value.rules[ruleIndex].value = ''
ruleGroup.value.rules[ruleIndex].operator = value
emitUpdate()
}
@@ -157,44 +188,50 @@ const emitUpdate = () => {
}
const conversationFields = {
content: {
label: 'Content'
},
subject: {
label: 'Subject'
},
status: {
label: 'Status'
},
priority: {
label: 'Priority'
},
assigned_team: {
label: 'Assigned team'
},
assigned_user: {
label: 'Assigned user'
}
content: { label: 'Content' },
subject: { label: 'Subject' },
status: { label: 'Status' },
priority: { label: 'Priority' },
assigned_team: { label: 'Assigned team' },
assigned_user: { label: 'Assigned user' }
}
const operators = {
contains: {
label: 'Contains'
},
not_contains: {
label: 'Not contains'
},
equals: {
label: 'Equals'
},
not_equals: {
label: 'Not equals'
},
set: {
label: 'Set'
},
not_set: {
label: 'Not set'
const fieldOperators = {
content: ["contains", "not contains", "equals", "not equals", "set", "not set"],
subject: ["contains", "not contains", "equals", "not equals", "set", "not set"],
status: ["equals", "not equals", "set", "not set"],
priority: ["equals", "not equals", "set", "not set"],
assigned_team: ["set", "not set"],
assigned_user: ["set", "not set"]
}
const fieldOptions = {
status: statuses,
priority: priorities,
}
const getFieldOperators = (field) => {
return fieldOperators[field] || []
}
const getFieldOptions = (field) => {
return fieldOptions[field]?.value || []
}
const inputType = (index) => {
const field = ruleGroup.value.rules[index]?.field
const operator = ruleGroup.value.rules[index]?.operator
if (["status", "priority"].includes(field)) {
return "select"
}
if (["equals", "not equals", "contains", "not contains"].includes(operator)) {
return "text"
}
return ""
}
const showInput = (index) => {
const operator = ruleGroup.value.rules[index]?.operator
return !["set", "not set"].includes(operator)
}
</script>

View File

@@ -68,7 +68,7 @@
<FormField name="allowed_file_upload_extensions" v-slot="{ componentField }">
<FormItem>
<FormLabel>Allowed file extensions</FormLabel>
<FormLabel>Allowed file upload extensions</FormLabel>
<FormControl>
<TagsInput v-model="componentField.modelValue">
<TagsInputItem v-for="item in componentField.modelValue" :key="item" :value="item">

View File

@@ -26,7 +26,7 @@
<!-- Header end -->
<!-- Messages & reply box -->
<div class="flex flex-col h-screen">
<div class="flex flex-col h-screen" v-auto-animate>
<MessageList class="flex-1" />
<ReplyBox class="h-max mb-12" />
</div>
@@ -37,6 +37,7 @@
<script setup>
import { computed, ref, onMounted } from 'vue'
import { vAutoAnimate } from '@formkit/auto-animate/vue'
import { useConversationStore } from '@/stores/conversation'
import { Badge } from '@/components/ui/badge'
import {

View File

@@ -28,7 +28,7 @@
<Editor @keydown="handleKeydown" @editorText="handleEditorText" :placeholder="editorPlaceholder" :isBold="isBold"
:clearContent="clearContent" :isItalic="isItalic" @updateBold="updateBold" @updateItalic="updateItalic"
@contentCleared="handleContentCleared" @contentSet="clearContentToSet" @editorReady="onEditorReady"
:messageType="messageType" :contentToSet="contentToSet" :cannedResponses="cannedResponsesStore.responses" />
:messageType="messageType" :contentToSet="contentToSet" :cannedResponses="cannedResponses" />
<!-- Attachments preview -->
<AttachmentsPreview :attachments="uploadedFiles" :onDelete="handleOnFileDelete"></AttachmentsPreview>
@@ -52,7 +52,6 @@ import api from '@/api'
import Editor from './ConversationTextEditor.vue'
import { useConversationStore } from '@/stores/conversation'
import { useCannedResponses } from '@/stores/canned_responses'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useEmitter } from '@/composables/useEmitter'
import AttachmentsPreview from '@/components/attachment/AttachmentsPreview.vue'
@@ -66,7 +65,6 @@ const editorText = ref('')
const editorHTML = ref('')
const contentToSet = ref('')
const conversationStore = useConversationStore()
const cannedResponsesStore = useCannedResponses()
const filteredCannedResponses = ref([])
const uploadedFiles = ref([])
const messageType = ref('reply')
@@ -74,10 +72,17 @@ const selectedResponseIndex = ref(-1)
const responsesList = ref(null)
let editorInstance = null
onMounted(() => {
cannedResponsesStore.fetchAll()
onMounted(async () => {
try {
const resp = await api.getCannedResponses()
cannedResponses.value = resp.data.data
} catch (error) {
console.error(error)
}
})
const cannedResponses = ref([])
const updateBold = (newState) => {
isBold.value = newState
}
@@ -105,7 +110,7 @@ const filterCannedResponses = (input) => {
const searchText = input.substring(lastSlashIndex + 1).trim()
// Filter canned responses based on the search text
filteredCannedResponses.value = cannedResponsesStore.responses.filter((response) =>
filteredCannedResponses.value = cannedResponses.value.filter((response) =>
response.title.toLowerCase().includes(searchText.toLowerCase())
)

View File

@@ -1,6 +1,5 @@
<template>
<div class="h-screen">
<!-- Filters -->
<ConversationListFilters v-model:type="conversationType"></ConversationListFilters>
@@ -11,12 +10,15 @@
:message="conversationStore.conversations.errorMessage" :icon="MessageCircleWarning"></EmptyList>
<div class="h-screen overflow-y-scroll pb-[180px] flex flex-col">
<!-- Item -->
<ConversationListItem />
<!-- List skeleton -->
<div v-if="conversationsLoading">
<ConversationListItemSkeleton v-for="index in 8" :key="index"></ConversationListItemSkeleton>
<ConversationListItemSkeleton v-for="index in 10" :key="index"></ConversationListItemSkeleton>
</div>
<!-- Item -->
<div v-auto-animate>
<ConversationListItem :conversation="conversation" :currentConversation="conversationStore.current"
v-for="conversation in conversationStore.sortedConversations" :key="conversation.uuid" />
</div>
<!-- Load more -->
@@ -36,6 +38,7 @@
<script setup>
import { onMounted, watch, computed, onUnmounted } from 'vue'
import { vAutoAnimate } from '@formkit/auto-animate/vue'
import { useConversationStore } from '@/stores/conversation'
import { subscribeConversationsList } from '@/websocket.js'
import { CONVERSATION_LIST_TYPE } from '@/constants/conversation'
@@ -63,6 +66,7 @@ onMounted(() => {
onUnmounted(() => {
clearInterval(listRefreshInterval)
conversationStore.clearListReRenderInterval()
})
watch(conversationType, (newType) => {

View File

@@ -1,7 +1,6 @@
<template>
<div class="flex items-center cursor-pointer flex-row hover:bg-slate-50"
:class="{ 'bg-slate-100': conversation.uuid === conversationStore.current?.uuid }"
v-for="conversation in conversationStore.sortedConversations" :key="conversation.uuid"
:class="{ 'bg-slate-100': conversation.uuid === currentConversation?.uuid }"
@click="router.push('/conversations/' + conversation.uuid)">
<div class="pl-3">
@@ -12,7 +11,7 @@
</AvatarFallback>
</Avatar>
</div>
<div class="ml-3 w-full border-b pb-2">
<div class="flex justify-between pt-2 pr-3">
<div>
@@ -55,6 +54,10 @@ import { Mail, CheckCheck } from 'lucide-vue-next'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
const router = useRouter()
defineProps({
conversation: Object,
currentConversation: Object
})
const conversationStore = useConversationStore()
const getContactFullName = (uuid) => {
return conversationStore.getContactFullName(uuid)

View File

@@ -3,3 +3,9 @@ export const CONVERSATION_LIST_TYPE = {
UNASSIGNED: 'unassigned',
ALL: 'all'
}
export const CONVERSTION_WS_ACTIONS = {
SUB_LIST: 'conversations_list_sub',
SET_CURRENT: 'conversation_set_current',
UNSET_CURRENT: 'conversation_unset_current'
}

View File

@@ -1,20 +0,0 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import api from '@/api'
export const useAgents = defineStore('agents', () => {
const agents = ref([])
async function fetchAll() {
try {
const resp = await api.getAgents()
agents.value = resp.data.data
} catch (error) {
// Pass
} finally {
// Pass
}
}
return { agents, fetchAll }
})

View File

@@ -1,20 +0,0 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import api from '@/api'
export const useCannedResponses = defineStore('canned_responses', () => {
const responses = ref([])
async function fetchAll() {
try {
const resp = await api.getCannedResponses()
responses.value = resp.data.data
} catch (error) {
// Pass
} finally {
// Pass
}
}
return { responses, fetchAll }
})

View File

@@ -1,7 +1,7 @@
import { defineStore } from 'pinia'
import { computed, reactive, onUnmounted } from 'vue'
import { handleHTTPError } from '@/utils/http'
import { computed, reactive } from 'vue'
import { CONVERSATION_LIST_TYPE } from '@/constants/conversation'
import { handleHTTPError } from '@/utils/http'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents'
import api from '@/api'
@@ -42,10 +42,10 @@ export const useConversationStore = defineStore('conversation', () => {
}, 120000)
const emitter = useEmitter()
// Cleanup.
onUnmounted(() => {
// Clears the re-render interval
function clearListReRenderInterval() {
clearInterval(reRenderInterval)
})
}
// Sort conversations by last_message_at
const sortedConversations = computed(() => {
@@ -96,6 +96,8 @@ export const useConversationStore = defineStore('conversation', () => {
conversation.data = resp.data.data
// Mark this conversation as read.
markAsRead(uuid)
// Reset messages state.
resetMessages()
} catch (error) {
conversation.errorMessage = handleHTTPError(error).message
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
@@ -128,8 +130,6 @@ export const useConversationStore = defineStore('conversation', () => {
// Fetches messages of a conversation.
async function fetchMessages (uuid) {
// Reset state.
resetMessages()
messages.loading = true
try {
const response = await api.getConversationMessages(uuid, messages.page)
@@ -394,22 +394,11 @@ export const useConversationStore = defineStore('conversation', () => {
}
}
function $reset () {
// Reset conversations state
conversations.data = []
conversations.loading = false
conversations.page = 1
conversations.hasMore = true
conversations.errorMessage = ''
// Reset conversation state
function resetCurrentConversation () {
conversation.data = null
conversation.participants = {}
conversation.loading = false
conversation.loading = false,
conversation.errorMessage = ''
// Reset messages state
resetMessages()
}
function resetMessages () {
@@ -429,6 +418,7 @@ export const useConversationStore = defineStore('conversation', () => {
sortedMessages,
current,
currentContactName,
clearListReRenderInterval,
conversationUUIDExists,
updateConversationProp,
addNewConversation,
@@ -447,6 +437,7 @@ export const useConversationStore = defineStore('conversation', () => {
updatePriority,
updateStatus,
updateConversationLastMessage,
$reset
resetMessages,
resetCurrentConversation,
}
})

View File

@@ -1,5 +1,8 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { handleHTTPError } from '@/utils/http'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents'
import api from '@/api'
export const useUserStore = defineStore('user', () => {
@@ -7,6 +10,7 @@ export const useUserStore = defineStore('user', () => {
const userFirstName = ref('')
const userLastName = ref('')
const userPermissions = ref([])
const emitter = useEmitter()
// Setters
const setAvatar = (avatar) => {
@@ -31,22 +35,25 @@ export const useUserStore = defineStore('user', () => {
const getFullName = computed(() => {
return `${userFirstName.value} ${userLastName.value}`
})
// Fetch current user data
// Fetches current user.
const getCurrentUser = async () => {
try {
const response = await api.getCurrentUser()
const userData = response?.data?.data
if (userData) {
const { avatar_url, first_name, last_name, permissions } = userData
setAvatar("/uploads/" +avatar_url)
setAvatar("/uploads/" + avatar_url)
setFirstName(first_name)
setLastName(last_name)
userPermissions.value = permissions || []
}
} catch (error) {
console.error('Error fetching current user:', error)
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Something went wrong',
variant: 'destructive',
description: handleHTTPError(error).message
})
}
}

View File

@@ -9,20 +9,15 @@
<ConversationPlaceholder v-else></ConversationPlaceholder>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel
:min-size="10"
:default-size="16"
:max-size="30"
v-if="conversationStore.current"
class="shadow shadow-gray-300"
>
<ResizablePanel :min-size="10" :default-size="16" :max-size="30" v-if="conversationStore.current"
class="shadow shadow-gray-300">
<ConversationSideBar></ConversationSideBar>
</ResizablePanel>
</ResizablePanelGroup>
</template>
<script setup>
import { onMounted, watch } from 'vue'
import { onMounted, watch, onUnmounted } from 'vue'
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'
import ConversationList from '@/components/conversation/list/ConversationList.vue'
@@ -30,6 +25,7 @@ import Conversation from '@/components/conversation/ConversationPage.vue'
import ConversationSideBar from '@/components/conversation/sidebar/ConversationSideBar.vue'
import ConversationPlaceholder from '@/components/conversation/ConversationPlaceholder.vue'
import { useConversationStore } from '@/stores/conversation'
import { unsetCurrentConversation, setCurrentConversation } from '@/websocket'
const props = defineProps({
uuid: String
@@ -40,10 +36,17 @@ onMounted(() => {
fetchConversation(props.uuid)
})
onUnmounted(() => {
unsetCurrentConversation()
conversationStore.resetCurrentConversation()
conversationStore.resetMessages()
})
watch(
() => props.uuid,
(newUUID, oldUUID) => {
if (newUUID !== oldUUID) {
unsetCurrentConversation()
fetchConversation(newUUID)
}
}
@@ -53,6 +56,7 @@ const fetchConversation = (uuid) => {
if (!uuid) return
conversationStore.fetchParticipants(uuid)
conversationStore.fetchConversation(uuid)
setCurrentConversation(uuid)
conversationStore.fetchMessages(uuid)
conversationStore.updateAssigneeLastSeen(uuid)
}

View File

@@ -4,7 +4,7 @@
<div>
<DashboardGreet></DashboardGreet>
</div>
<div class="mt-7">
<div class="mt-7" v-auto-animate>
<Card :counts="cardCounts" :labels="agentCountCardsLabels" />
</div>
<div class="flex my-7 justify-between items-center space-x-5">
@@ -23,6 +23,7 @@ import { onMounted, ref } from 'vue'
import { useToast } from '@/components/ui/toast/use-toast'
import api from '@/api'
import { vAutoAnimate } from '@formkit/auto-animate/vue'
import Card from '@/components/dashboard/DashboardCard.vue'
import LineChart from '@/components/dashboard/DashboardLineChart.vue'
import BarChart from '@/components/dashboard/DashboardBarChart.vue'

View File

@@ -1,4 +1,5 @@
import { useConversationStore } from './stores/conversation';
import { CONVERSTION_WS_ACTIONS } from './constants/conversation';
let socket;
let reconnectInterval = 1000;
@@ -10,19 +11,18 @@ let convStore;
function initializeWebSocket () {
// TODO: Update URL.
socket = new WebSocket('ws://localhost:9009/api/ws');
socket.addEventListener('open', handleOpen);
socket.addEventListener('message', handleMessage);
socket.addEventListener('error', handleError);
socket.addEventListener('close', handleClose);
socket = new WebSocket('ws://localhost:9009/api/ws')
socket.addEventListener('open', handleOpen)
socket.addEventListener('message', handleMessage)
socket.addEventListener('error', handleError)
socket.addEventListener('close', handleClose)
}
function handleOpen () {
console.log('WebSocket connection established');
console.log('WebSocket connection established')
reconnectInterval = 1000;
if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
clearTimeout(reconnectTimeout)
reconnectTimeout = null;
}
}
@@ -30,37 +30,37 @@ function handleOpen () {
function handleMessage (event) {
try {
if (event.data) {
const data = JSON.parse(event.data);
const data = JSON.parse(event.data)
switch (data.type) {
case 'new_message':
convStore.updateConversationLastMessage(data.data);
convStore.updateConversationMessageList(data.data);
convStore.updateConversationLastMessage(data.data)
convStore.updateConversationMessageList(data.data)
break;
case 'message_prop_update':
convStore.updateMessageProp(data.data);
convStore.updateMessageProp(data.data)
break;
case 'new_conversation':
convStore.addNewConversation(data.data);
convStore.addNewConversation(data.data)
break;
case 'conversation_prop_update':
convStore.updateConversationProp(data.data);
convStore.updateConversationProp(data.data)
break;
default:
console.warn(`Unknown websocket event type: ${data.type}`);
console.warn(`Unknown websocket event type: ${data.type}`)
}
}
} catch (error) {
console.error('Error handling WebSocket message:', error);
console.error('Error handling WebSocket message:', error)
}
}
function handleError (event) {
console.error('WebSocket error observed:', event);
console.error('WebSocket error observed:', event)
}
function handleClose () {
if (!manualClose) {
reconnect();
reconnect()
}
}
@@ -68,58 +68,77 @@ function reconnect () {
if (isReconnecting) return;
isReconnecting = true;
reconnectTimeout = setTimeout(() => {
initializeWebSocket();
reconnectInterval = Math.min(reconnectInterval * 2, maxReconnectInterval);
initializeWebSocket()
reconnectInterval = Math.min(reconnectInterval * 2, maxReconnectInterval)
isReconnecting = false;
}, reconnectInterval);
}, reconnectInterval)
}
function setupNetworkListeners () {
window.addEventListener('online', () => {
if (!isReconnecting && socket.readyState !== WebSocket.OPEN) {
reconnectInterval = 1000;
reconnect();
reconnect()
}
});
})
}
export function initWS () {
convStore = useConversationStore();
initializeWebSocket();
setupNetworkListeners();
convStore = useConversationStore()
initializeWebSocket()
setupNetworkListeners()
}
function waitForWebSocketOpen (callback) {
if (socket.readyState === WebSocket.OPEN) {
callback();
callback()
} else {
socket.addEventListener('open', function handler () {
socket.removeEventListener('open', handler);
callback();
});
socket.removeEventListener('open', handler)
callback()
})
}
}
export function sendMessage (message) {
waitForWebSocketOpen(() => {
socket.send(JSON.stringify(message));
});
socket.send(JSON.stringify(message))
})
}
export function subscribeConversationsList (type, filter) {
const message = {
action: 'conversations_list_sub',
action: CONVERSTION_WS_ACTIONS.SUB_LIST,
type: type,
filter: filter
};
}
waitForWebSocketOpen(() => {
socket.send(JSON.stringify(message));
});
socket.send(JSON.stringify(message))
})
}
export function setCurrentConversation (uuid) {
const message = {
action: CONVERSTION_WS_ACTIONS.SET_CURRENT,
uuid: uuid,
}
waitForWebSocketOpen(() => {
socket.send(JSON.stringify(message))
})
}
export function unsetCurrentConversation () {
const message = {
action: CONVERSTION_WS_ACTIONS.UNSET_CURRENT
}
waitForWebSocketOpen(() => {
socket.send(JSON.stringify(message))
})
}
export function closeWebSocket () {
manualClose = true;
if (socket) {
socket.close();
socket.close()
}
}

View File

@@ -2,6 +2,8 @@ package auth
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"net/http"
@@ -20,6 +22,10 @@ import (
"golang.org/x/oauth2"
)
const (
csrfTokenLength = 20
)
type OIDCclaim struct {
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
@@ -162,6 +168,22 @@ func (a *Auth) SaveSession(user models.User, r *fastglue.Request) error {
return nil
}
// SetCSRFCookie sets the CSRF token in the response cookie
func (a *Auth) SetCSRFCookie(r *fastglue.Request) error {
token, err := generateCSRFToken()
if err != nil {
return err
}
var csrfCookie fasthttp.Cookie
csrfCookie.SetKey("csrf_token")
csrfCookie.SetValue(token)
csrfCookie.SetPath("/")
csrfCookie.SetSecure(true)
csrfCookie.SetHTTPOnly(false)
r.RequestCtx.Response.Header.SetCookie(&csrfCookie)
return nil
}
// ValidateSession validates session and returns the user.
func (a *Auth) ValidateSession(r *fastglue.Request) (models.User, error) {
sess, err := a.sess.Acquire(r.RequestCtx, r, r)
@@ -206,6 +228,15 @@ func (a *Auth) DestroySession(r *fastglue.Request) error {
return nil
}
// generateCSRFToken creates a random CSRF token
func generateCSRFToken() (string, error) {
b := make([]byte, csrfTokenLength)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(b), nil
}
// getRequestCookie returns fashttp.Cookie for the given name.
func getRequestCookie(name string, r *fastglue.Request) (*fasthttp.Cookie, error) {
// Cookie value.

View File

@@ -1,3 +1,4 @@
// package authz provides Casbin-based authorization.
package authz
import (
@@ -20,17 +21,17 @@ type Enforcer struct {
}
const casbinModel = `
[request_definition]
r = sub, obj, act
[request_definition]
r = sub, obj, act
[policy_definition]
p = sub, obj, act
[policy_definition]
p = sub, obj, act
[policy_effect]
e = some(where (p.eft == allow))
[policy_effect]
e = some(where (p.eft == allow))
[matchers]
m = r.sub == p.sub && r.obj == p.obj && r.act == p.act
[matchers]
m = r.sub == p.sub && r.obj == p.obj && r.act == p.act
`
// NewEnforcer initializes a new Enforcer with the hardcoded model
@@ -39,7 +40,6 @@ func NewEnforcer(lo *logf.Logger) (*Enforcer, error) {
if err != nil {
return nil, fmt.Errorf("failed to create Casbin model: %v", err)
}
e, err := casbin.NewEnforcer(m)
if err != nil {
return nil, fmt.Errorf("failed to create Casbin enforcer: %v", err)
@@ -56,11 +56,10 @@ func (e *Enforcer) LoadPermissions(user umodels.User) error {
return fmt.Errorf("invalid permission format: %s", perm)
}
permObj, permAct := parts[0], parts[1]
ok, err := e.enforcer.HasPolicy(strconv.Itoa(user.ID), permObj, permAct)
userID, permObj, permAct := strconv.Itoa(user.ID), parts[0], parts[1]
ok, err := e.enforcer.HasPolicy(userID, permObj, permAct)
if err != nil || !ok {
if _, err := e.enforcer.AddPolicy(strconv.Itoa(user.ID), permObj, permAct); err != nil {
if _, err := e.enforcer.AddPolicy(userID, permObj, permAct); err != nil {
return fmt.Errorf("failed to add policy: %v", err)
}
}
@@ -85,7 +84,7 @@ func (e *Enforcer) Enforce(user umodels.User, obj, act string) (bool, error) {
}
// EnforceConversationAccess checks if a user has access to a conversation based on their permissions.
// It returns true if the user has read_all permission, or read_team permission and is in the assigned team,
// It returns true if the user has read_all permission, or read_assigned permission and is in the assigned team,
// or read_assigned permission and is the assigned user. Returns false otherwise.
func (e *Enforcer) EnforceConversationAccess(user umodels.User, conversation cmodels.Conversation) (bool, error) {
// Check for `read_all` permission
@@ -106,8 +105,8 @@ func (e *Enforcer) EnforceConversationAccess(user umodels.User, conversation cmo
return true, nil
}
// Check for `read_team` permission
allowed, err = e.enforcer.Enforce(strconv.Itoa(user.ID), "conversations", "read_team")
// Check for `read_assigned` permission
allowed, err = e.enforcer.Enforce(strconv.Itoa(user.ID), "conversations", "read_assigned")
if err != nil {
return false, envelope.NewError(envelope.GeneralError, "Error checking permissions", nil)
}
@@ -121,3 +120,20 @@ func (e *Enforcer) EnforceConversationAccess(user umodels.User, conversation cmo
return false, nil
}
// EnforceMediaAccess checks for read access on linked model to media.
func (e *Enforcer) EnforceMediaAccess(user umodels.User, model string) (bool, error) {
switch model {
case "messages":
allowed, err := e.Enforce(user, model, "read")
if err != nil {
return false, envelope.NewError(envelope.GeneralError, "Error checking permissions", nil)
}
if !allowed {
return false, envelope.NewError(envelope.UnauthorizedError, "Permission denied", nil)
}
default:
return true, nil
}
return true, nil
}

View File

@@ -0,0 +1,124 @@
package models
const (
// Conversation
PermConversationsReadAll = "conversations:read_all"
PermConversationsReadUnassigned = "conversations:read_unassigned"
PermConversationsReadAssigned = "conversations:read_assigned"
PermConversationsRead = "conversations:read"
PermConversationsUpdateUserAssignee = "conversations:update_user_assignee"
PermConversationsUpdateTeamAssignee = "conversations:update_team_assignee"
PermConversationsUpdatePriority = "conversations:update_priority"
PermConversationsUpdateStatus = "conversations:update_status"
PermConversationsUpdateTags = "conversations:update_tags"
PermMessagesRead = "messages:read"
PermMessagesWrite = "messages:write"
// Conversation Status
PermStatusRead = "status:read"
PermStatusWrite = "status:write"
PermStatusDelete = "status:delete"
// Admin
PermAdminRead = "admin:read"
// Settings
PermSettingsGeneralWrite = "settings_general:write"
PermSettingsNotificationsWrite = "settings_notifications:write"
PermSettingsNotificationsRead = "settings_notifications:read"
// OpenID Connect SSO
PermOIDCRead = "oidc:read"
PermOIDCWrite = "oidc:write"
PermOIDCDelete = "oidc:delete"
// Tags
PermTagsWrite = "tags:write"
PermTagsDelete = "tags:delete"
// Canned Responses
PermCannedResponsesWrite = "canned_responses:write"
PermCannedResponsesDelete = "canned_responses:delete"
// Dashboard
PermDashboardGlobalRead = "dashboard_global:read"
// Users
PermUsersRead = "users:read"
PermUsersWrite = "users:write"
// Teams
PermTeamsRead = "teams:read"
PermTeamsWrite = "teams:write"
// Automations
PermAutomationsRead = "automations:read"
PermAutomationsWrite = "automations:write"
PermAutomationsDelete = "automations:delete"
// Inboxes
PermInboxesRead = "inboxes:read"
PermInboxesWrite = "inboxes:write"
PermInboxesDelete = "inboxes:delete"
// Roles
PermRolesRead = "roles:read"
PermRolesWrite = "roles:write"
PermRolesDelete = "roles:delete"
// Templates
PermTemplatesRead = "templates:read"
PermTemplatesWrite = "templates:write"
PermTemplatesDelete = "templates:delete"
)
var validPermissions = map[string]struct{}{
PermConversationsReadAll: {},
PermConversationsReadUnassigned: {},
PermConversationsReadAssigned: {},
PermConversationsRead: {},
PermConversationsUpdateUserAssignee: {},
PermConversationsUpdateTeamAssignee: {},
PermConversationsUpdatePriority: {},
PermConversationsUpdateStatus: {},
PermConversationsUpdateTags: {},
PermMessagesRead: {},
PermMessagesWrite: {},
PermStatusRead: {},
PermStatusWrite: {},
PermStatusDelete: {},
PermAdminRead: {},
PermSettingsGeneralWrite: {},
PermSettingsNotificationsWrite: {},
PermSettingsNotificationsRead: {},
PermOIDCRead: {},
PermOIDCWrite: {},
PermOIDCDelete: {},
PermTagsWrite: {},
PermTagsDelete: {},
PermCannedResponsesWrite: {},
PermCannedResponsesDelete: {},
PermDashboardGlobalRead: {},
PermUsersRead: {},
PermUsersWrite: {},
PermTeamsRead: {},
PermTeamsWrite: {},
PermAutomationsRead: {},
PermAutomationsWrite: {},
PermAutomationsDelete: {},
PermInboxesRead: {},
PermInboxesWrite: {},
PermInboxesDelete: {},
PermRolesRead: {},
PermRolesWrite: {},
PermRolesDelete: {},
PermTemplatesRead: {},
PermTemplatesWrite: {},
PermTemplatesDelete: {},
}
// IsValidPermission retuns true if it's a valid perm.
func IsValidPermission(permission string) bool {
_, exists := validPermissions[permission]
return exists
}

View File

@@ -54,7 +54,7 @@ func New(teamManager *team.Manager, conversationManager *conversation.Manager, s
// Run initiates the conversation assignment process and is to be invoked as a goroutine.
// This function continuously assigns unassigned conversations to agents at regular intervals.
func (e *Engine) Run(ctx context.Context) {
ticker := time.NewTicker(10 * time.Second)
ticker := time.NewTicker(60 * time.Second)
defer ticker.Stop()
for {
select {

View File

@@ -7,6 +7,7 @@ import (
"database/sql"
"embed"
"encoding/json"
"fmt"
"sync"
"time"
@@ -43,7 +44,7 @@ type Opts struct {
type ConversationStore interface {
GetConversation(uuid string) (cmodels.Conversation, error)
GetRecentConversations(t time.Time) ([]cmodels.Conversation, error)
GetConversationsCreatedAfter(t time.Time) ([]cmodels.Conversation, error)
UpdateConversationTeamAssignee(uuid string, teamID int, actor umodels.User) error
UpdateConversationUserAssignee(uuid string, assigneeID int, actor umodels.User) error
UpdateConversationStatus(uuid string, status []byte, actor umodels.User) error
@@ -65,6 +66,7 @@ func New(systemUser umodels.User, opt Opts) (*Engine, error) {
var (
q queries
e = &Engine{
systemUser: systemUser,
lo: opt.Lo,
newConversationQ: make(chan string, 5000),
updateConversationQ: make(chan string, 5000),
@@ -108,9 +110,11 @@ func (e *Engine) Run(ctx context.Context) {
case <-ctx.Done():
return
case conversationUUID := <-e.newConversationQ:
e.lo.Info("evaluating new conversation rules", "uuid", conversationUUID)
newConversationSemaphore <- struct{}{}
go e.handleNewConversation(conversationUUID, newConversationSemaphore)
case conversationUUID := <-e.updateConversationQ:
e.lo.Info("evaluating conversation rules on update", "uuid", conversationUUID)
updateConversationSemaphore <- struct{}{}
go e.handleUpdateConversation(conversationUUID, updateConversationSemaphore)
case <-ticker.C:
@@ -216,11 +220,12 @@ func (e *Engine) handleTimeTrigger(semaphore chan struct{}) {
defer func() { <-semaphore }()
thirtyDaysAgo := time.Now().Add(-30 * 24 * time.Hour)
conversations, err := e.conversationStore.GetRecentConversations(thirtyDaysAgo)
conversations, err := e.conversationStore.GetConversationsCreatedAfter(thirtyDaysAgo)
if err != nil {
return
}
rules := e.filterRulesByType(models.RuleTypeTimeTrigger)
e.lo.Debug("fetched conversations for evaluating time triggers", "conversations_count", len(conversations), "rules_count", len(rules))
for _, conversation := range conversations {
e.evalConversationRules(rules, conversation)
}
@@ -249,26 +254,33 @@ func (e *Engine) EvaluateConversationUpdateRules(conversationUUID string) {
// queryRules fetches automation rules from the database.
func (e *Engine) queryRules() []models.Rule {
var (
rulesJSON []string
rules []models.Rule
rules []struct {
Type string `db:"type"`
Rules string `db:"rules"`
}
filteredRules []models.Rule
)
err := e.q.GetEnabledRules.Select(&rulesJSON)
err := e.q.GetEnabledRules.Select(&rules)
if err != nil {
e.lo.Error("error fetching automation rules", "error", err)
return rules
return filteredRules
}
e.lo.Debug("fetched rules from db", "count", len(rulesJSON))
e.lo.Info("fetched rules from db", "count", len(rules))
for _, ruleJSON := range rulesJSON {
for _, rule := range rules {
var rulesBatch []models.Rule
if err := json.Unmarshal([]byte(ruleJSON), &rulesBatch); err != nil {
if err := json.Unmarshal([]byte(rule.Rules), &rulesBatch); err != nil {
e.lo.Error("error unmarshalling rule JSON", "error", err)
continue
}
rules = append(rules, rulesBatch...)
// Set the Type for each rule in rulesBatch
for i := range rulesBatch {
rulesBatch[i].Type = rule.Type
}
filteredRules = append(filteredRules, rulesBatch...)
}
return rules
return filteredRules
}
// filterRulesByType filters rules by type.
@@ -278,6 +290,7 @@ func (e *Engine) filterRulesByType(ruleType string) []models.Rule {
var filteredRules []models.Rule
for _, rule := range e.rules {
fmt.Println(rule)
if rule.Type == ruleType {
filteredRules = append(filteredRules, rule)
}

View File

@@ -14,29 +14,29 @@ import (
// the corresponding actions are executed.
func (e *Engine) evalConversationRules(rules []models.Rule, conversation cmodels.Conversation) {
for _, rule := range rules {
e.lo.Debug("evaluating rule for conversation", "rule", rule, "conversation_uuid", conversation.UUID)
// At max there can be only 2 groups.
if len(rule.Groups) > 2 {
e.lo.Warn("more than 2 groups found for rules")
e.lo.Warn("WARNING: more than 2 groups found for rules skipping evaluation")
continue
}
var results []bool
for _, group := range rule.Groups {
e.lo.Debug("evaluating group rule", "logical_op", group.LogicalOp)
result := e.evaluateGroup(group.Rules, group.LogicalOp, conversation)
e.lo.Debug("group evaluation status", "status", result)
e.lo.Debug("evaluating group rules", "logical_op", group.LogicalOp, "result", result, "conversation_uuid", conversation.UUID)
results = append(results, result)
}
if evaluateFinalResult(results, rule.GroupOperator) {
e.lo.Debug("rule fully evaluated, executing actions")
e.lo.Debug("rule evaluation successfull executing actions", "conversation_uuid", conversation.UUID)
// All group rule evaluations successful, execute the actions.
for _, action := range rule.Actions {
e.applyAction(action, conversation)
}
} else {
e.lo.Debug("rule evaluation failed, NOT executing actions")
e.lo.Debug("rule evaluation failed", "conversation_uuid", conversation.UUID)
}
}
}
@@ -106,16 +106,16 @@ func (e *Engine) evaluateRule(rule models.RuleDetail, conversation cmodels.Conve
valueToCompare = conversation.Status.String
case models.ConversationFieldPriority:
valueToCompare = conversation.Priority.String
case models.ConversationFieldAssignedTeamID:
case models.ConversationFieldAssignedTeam:
if conversation.AssignedTeamID.Valid {
valueToCompare = strconv.Itoa(conversation.AssignedTeamID.Int)
}
case models.ConversationFieldAssignedUserID:
case models.ConversationFieldAssignedUser:
if conversation.AssignedUserID.Valid {
valueToCompare = strconv.Itoa(conversation.AssignedUserID.Int)
}
default:
e.lo.Error("rule field not recognized", "field", rule.Field)
e.lo.Error("unrecognized rule field", "field", rule.Field)
return false
}
@@ -124,8 +124,9 @@ func (e *Engine) evaluateRule(rule models.RuleDetail, conversation cmodels.Conve
rule.Value = strings.ToLower(rule.Value)
}
e.lo.Debug("comparing values", "conversation_value", valueToCompare, "rule_value", rule.Value)
e.lo.Debug("evaluating rule", "rule_field", rule.Field, "rule_operator", rule.Operator, "rule_value", rule.Value, "compared_with", valueToCompare, "coversation_uuid", conversation.UUID)
// Compare with set operator.
switch rule.Operator {
case models.RuleEquals:
conditionMet = valueToCompare == rule.Value
@@ -140,9 +141,10 @@ func (e *Engine) evaluateRule(rule models.RuleDetail, conversation cmodels.Conve
case models.RuleNotSet:
conditionMet = len(valueToCompare) == 0
default:
e.lo.Error("rule logical operator not recognized", "operator", rule.Operator)
e.lo.Error("unrecognized rule logical operator", "operator", rule.Operator)
return false
}
e.lo.Debug("rule conditions met", "coversation_uuid", conversation.UUID)
return conditionMet
}
@@ -150,6 +152,7 @@ func (e *Engine) evaluateRule(rule models.RuleDetail, conversation cmodels.Conve
func (e *Engine) applyAction(action models.RuleAction, conversation cmodels.Conversation) error {
switch action.Type {
case models.ActionAssignTeam:
e.lo.Debug("executing assign team action", "value", action.Action, "conversation_uuid", conversation.UUID)
teamID, err := strconv.Atoi(action.Action)
if err != nil {
e.lo.Error("error converting string to int", "string", action.Action, "error", err)
@@ -159,6 +162,7 @@ func (e *Engine) applyAction(action models.RuleAction, conversation cmodels.Conv
return err
}
case models.ActionAssignUser:
e.lo.Debug("executing assign user action", "value", action.Action, "conversation_uuid", conversation.UUID)
agentID, err := strconv.Atoi(action.Action)
if err != nil {
e.lo.Error("error converting string to int", "string", action.Action, "error", err)
@@ -168,10 +172,12 @@ func (e *Engine) applyAction(action models.RuleAction, conversation cmodels.Conv
return err
}
case models.ActionSetPriority:
e.lo.Debug("executing set priority action", "value", action.Action, "conversation_uuid", conversation.UUID)
if err := e.conversationStore.UpdateConversationPriority(conversation.UUID, []byte(action.Action), e.systemUser); err != nil {
return err
}
case models.ActionSetStatus:
e.lo.Debug("executing set status action", "value", action.Action, "conversation_uuid", conversation.UUID)
if err := e.conversationStore.UpdateConversationStatus(conversation.UUID, []byte(action.Action), e.systemUser); err != nil {
return err
}

View File

@@ -15,22 +15,22 @@ const (
OperatorOR = "OR"
RuleContains = "contains"
RuleNotContains = "not_contains"
RuleNotContains = "not contains"
RuleEquals = "equals"
RuleNotEqual = "not_equals"
RuleNotEqual = "not equals"
RuleSet = "set"
RuleNotSet = "not_set"
RuleNotSet = "not set"
RuleTypeNewConversation = "new_conversation"
RuleTypeConversationUpdate = "conversation_update"
RuleTypeTimeTrigger = "time_trigger"
ConversationFieldSubject = "subject"
ConversationFieldContent = "content"
ConversationFieldStatus = "status"
ConversationFieldPriority = "priority"
ConversationFieldAssignedUserID = "assigned_user_id"
ConversationFieldAssignedTeamID = "assigned_team_id"
ConversationFieldSubject = "subject"
ConversationFieldContent = "content"
ConversationFieldStatus = "status"
ConversationFieldPriority = "priority"
ConversationFieldAssignedUser = "assigned_user"
ConversationFieldAssignedTeam = "assigned_team"
)
// RuleRecord represents a rule record in the database

View File

@@ -1,5 +1,6 @@
-- name: get-enabled-rules
select
select
type,
rules
from automation_rules where disabled is not TRUE;

View File

@@ -142,10 +142,10 @@ type queries struct {
GetConversationID *sqlx.Stmt `query:"get-conversation-id"`
GetConversationUUID *sqlx.Stmt `query:"get-conversation-uuid"`
GetConversation *sqlx.Stmt `query:"get-conversation"`
GetRecentConversations *sqlx.Stmt `query:"get-recent-conversations"`
GetConversationsCreatedAfter *sqlx.Stmt `query:"get-conversations-created-after"`
GetUnassignedConversations *sqlx.Stmt `query:"get-unassigned-conversations"`
GetConversations string `query:"get-conversations"`
GetConversationsUUIDs string `query:"get-conversations-uuids"`
GetConversationsListUUIDs string `query:"get-conversations-list-uuids"`
GetConversationParticipants *sqlx.Stmt `query:"get-conversation-participants"`
GetAssignedConversations *sqlx.Stmt `query:"get-assigned-conversations"`
@@ -203,10 +203,10 @@ func (c *Manager) GetConversation(uuid string) (models.Conversation, error) {
return conversation, nil
}
// GetRecentConversations retrieves conversations created after the specified time.
func (c *Manager) GetRecentConversations(time time.Time) ([]models.Conversation, error) {
// GetConversationsCreatedAfter retrieves conversations created after the specified time.
func (c *Manager) GetConversationsCreatedAfter(time time.Time) ([]models.Conversation, error) {
var conversations = make([]models.Conversation, 0)
if err := c.q.GetRecentConversations.Select(&conversations, time); err != nil {
if err := c.q.GetConversationsCreatedAfter.Select(&conversations, time); err != nil {
if err == sql.ErrNoRows {
c.lo.Error("conversations not found", "created_after", time)
return conversations, err
@@ -334,11 +334,11 @@ func (c *Manager) GetConversations(userID int, typ, order, orderBy string, page,
return conversations, nil
}
// GetConversationUUIDs retrieves the UUIDs of conversations based on user ID, type, and optional filtering, ordering, and pagination.
func (c *Manager) GetConversationUUIDs(userID, page, pageSize int, typ string) ([]string, error) {
// GetConversationsListUUIDs retrieves the UUIDs of conversations list.
func (c *Manager) GetConversationsListUUIDs(userID, page, pageSize int, typ string) ([]string, error) {
var ids = make([]string, 0)
query, qArgs, err := c.generateConversationsListQuery(userID, c.q.GetConversationsUUIDs, typ, "", "", page, pageSize)
query, qArgs, err := c.generateConversationsListQuery(userID, c.q.GetConversationsListUUIDs, typ, "", "", page, pageSize)
if err != nil {
c.lo.Error("error generating conversations query", "error", err)
return ids, err

View File

@@ -412,8 +412,6 @@ func (m *Manager) processIncomingMessage(in models.IncomingMessage) error {
// Evaluate automation rules for this conversation.
if isNewConversation {
m.automation.EvaluateNewConversationRules(in.Message.ConversationUUID)
} else {
m.automation.EvaluateConversationUpdateRules(in.Message.ConversationUUID)
}
return nil
}

View File

@@ -34,7 +34,7 @@ FROM conversations c
LEFT JOIN priority p ON c.priority_id = p.id
WHERE 1=1 %s
-- name: get-conversations-uuids
-- name: get-conversations-list-uuids
SELECT
c.uuid
FROM conversations c
@@ -81,7 +81,7 @@ LEFT JOIN status s ON c.status_id = s.id
LEFT JOIN priority p ON c.priority_id = p.id
WHERE c.uuid = $1;
-- name: get-recent-conversations
-- name: get-conversations-created-after
SELECT
c.created_at,
c.updated_at,
@@ -111,7 +111,7 @@ LEFT JOIN users u ON u.id = c.assigned_user_id
LEFT JOIN teams at ON at.id = c.assigned_team_id
LEFT JOIN status s ON c.status_id = s.id
LEFT JOIN priority p ON c.priority_id = p.id
WHERE c.created_at > $1 AND c.uuid = 'e2f69c9f-17f5-4d09-9aae-12c2a79046a2';
WHERE c.created_at > $1;
-- name: get-conversation-id
SELECT id from conversations where uuid = $1;
@@ -428,7 +428,9 @@ WHERE source_id = ANY($1::text []);
-- name: get-conversation-by-message-id
SELECT
c.id,
c.uuid
c.uuid,
c.assigned_team_id,
c.assigned_user_id
FROM messages m
JOIN conversations c ON m.conversation_id = c.id
WHERE m.id = $1;

View File

@@ -4,6 +4,7 @@ package role
import (
"embed"
amodels "github.com/abhinavxd/artemis/internal/authz/models"
"github.com/abhinavxd/artemis/internal/dbutil"
"github.com/abhinavxd/artemis/internal/envelope"
"github.com/abhinavxd/artemis/internal/role/models"
@@ -83,6 +84,9 @@ func (t *Manager) Delete(id int) error {
// Create creates a new role.
func (u *Manager) Create(r models.Role) error {
if !u.areValidPerms(r.Permissions) {
return envelope.NewError(envelope.InputError, "Invalid permissions", nil)
}
if _, err := u.q.Insert.Exec(r.Name, r.Description, pq.Array(r.Permissions)); err != nil {
u.lo.Error("error inserting role", "error", err)
return envelope.NewError(envelope.GeneralError, "Error creating role", nil)
@@ -92,9 +96,23 @@ func (u *Manager) Create(r models.Role) error {
// Update updates an existing role.
func (u *Manager) Update(id int, r models.Role) error {
if !u.areValidPerms(r.Permissions) {
return envelope.NewError(envelope.InputError, "Invalid permissions", nil)
}
if _, err := u.q.Update.Exec(id, r.Name, r.Description, pq.Array(r.Permissions)); err != nil {
u.lo.Error("error updating role", "error", err)
return envelope.NewError(envelope.GeneralError, "Error updating role", nil)
}
return nil
}
// areValidPerms returns true if the permissions are one of the valid permissions
func (u *Manager) areValidPerms(permissions []string) bool {
for _, perm := range permissions {
if !amodels.IsValidPermission(perm) {
u.lo.Error("invalid permission", "permission", perm)
return false
}
}
return true
}

View File

@@ -5,7 +5,7 @@ ORDER BY u.updated_at DESC;
-- name: get-users-compact
SELECT u.id, u.first_name, u.last_name, u.disabled
FROM users u
FROM users u where u.email != 'System'
ORDER BY u.updated_at DESC;
-- name: get-email

View File

@@ -299,7 +299,7 @@ func promptAndHashPassword() ([]byte, error) {
for {
fmt.Print("Please set System admin password (min 8, max 50 characters, at least 1 uppercase letter, 1 number): ")
fmt.Scanf("%s", &password)
if isStringSystemUserPassword(password) {
if isStrongSystemUserPassword(password) {
break
}
fmt.Println("Password does not meet the strength requirements.")
@@ -322,8 +322,8 @@ func updateSystemUserPassword(db *sqlx.DB, hashedPassword []byte) error {
return nil
}
// isStringSystemUserPassword checks if the password meets the required strength for system user.
func isStringSystemUserPassword(password string) bool {
// isStrongSystemUserPassword checks if the password meets the required strength for system user.
func isStrongSystemUserPassword(password string) bool {
if len(password) < MinSystemUserPasswordLen || len(password) > MaxSystemUserPasswordLen {
return false
}

View File

@@ -26,7 +26,7 @@ type SafeBool struct {
func (b *SafeBool) Set(value bool) {
b.mu.Lock()
defer b.mu.Unlock()
b.flag = value
b.flag = value
}
// Get returns the value of the SafeBool.
@@ -50,6 +50,9 @@ type Client struct {
// To prevent pushes to the channel.
Closed SafeBool
// Currently opened conversation UUID.
CurrentConversationUUID string
// Buffered channel of outbound ws messages.
Send chan models.WSMessage
}
@@ -118,13 +121,27 @@ func (c *Client) processIncomingMessage(data []byte) {
// Add the new subscriptions.
for page := 1; page <= maxConversationsPagesToSub; page++ {
conversationUUIDs, err := c.Hub.conversationStore.GetConversationUUIDs(c.ID, page, maxConversationsPageSize, subReq.Type)
conversationUUIDs, err := c.Hub.conversationStore.GetConversationsListUUIDs(c.ID, page, maxConversationsPageSize, subReq.Type)
if err != nil {
log.Println("error fetching conversation ids", err)
continue
}
c.SubscribeConversations(c.ID, conversationUUIDs)
}
case models.ActionSetCurrentConversation:
var subReq models.ConversationCurrentSet
if err := json.Unmarshal(data, &subReq); err != nil {
c.SendError("error unmarshalling request")
return
}
if c.CurrentConversationUUID != subReq.UUID {
c.UnsubscribeConversation(c.ID, c.CurrentConversationUUID)
c.CurrentConversationUUID = subReq.UUID
c.SubscribeConversations(c.ID, []string{subReq.UUID})
}
case models.ActionUnsetCurrentConversation:
c.UnsubscribeConversation(c.ID, c.CurrentConversationUUID)
c.CurrentConversationUUID = ""
default:
c.SendError("unknown action")
}
@@ -176,6 +193,22 @@ func (c *Client) RemoveAllUserConversationSubscriptions(userID int) {
}
}
// UnsubscribeConversation unsubscribes the user from the specified conversation.
func (c *Client) UnsubscribeConversation(userID int, conversationUUID string) {
if userIDs, ok := c.Hub.conversationSubs[conversationUUID]; ok {
for i, id := range userIDs {
if id == userID {
c.Hub.conversationSubs[conversationUUID] = append(userIDs[:i], userIDs[i+1:]...)
break
}
}
// Remove the conversation from the map if no users are subscribed
if len(c.Hub.conversationSubs[conversationUUID]) == 0 {
delete(c.Hub.conversationSubs, conversationUUID)
}
}
}
// SendError sends an error message to client.
func (c *Client) SendError(msg string) {
out := models.Message{

View File

@@ -2,7 +2,10 @@ package models
// Action constants for WebSocket messages.
const (
ActionConversationsListSub = "conversations_list_sub"
ActionConversationsListSub = "conversations_list_sub"
ActionSetCurrentConversation = "conversation_set_current"
ActionUnsetCurrentConversation = "conversation_unset_current"
MessageTypeMessagePropUpdate = "message_prop_update"
MessageTypeConversationPropertyUpdate = "conversation_prop_update"
MessageTypeNewMessage = "new_message"
@@ -38,3 +41,8 @@ type ConversationsListSubscribe struct {
Type string `json:"type"`
Filter string `json:"filter"`
}
// ConversationCurrentSet represents a request to set current conversation
type ConversationCurrentSet struct {
UUID string `json:"uuid"`
}

View File

@@ -24,7 +24,7 @@ type Hub struct {
// ConversationStore defines the interface for retrieving conversation UUIDs.
type ConversationStore interface {
GetConversationUUIDs(userID, page, pageSize int, typ string) ([]string, error)
GetConversationsListUUIDs(userID, page, pageSize int, typ string) ([]string, error)
}
// NewHub creates a new Hub.

View File

@@ -120,13 +120,14 @@ CREATE TABLE roles (
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Create roles.
-- Roles.
INSERT INTO roles
(permissions, "name", description)
VALUES('{conversations:read_unassigned,conversations:read_all,conversations:read,conversations:read_assigned,conversations:update_user_assignee,conversations:update_team_assignee,conversations:update_priority,conversations:update_status,conversations:update_tags,messages:read,messages:write,templates:write,templates:read,roles:delete,roles:write,roles:read,inboxes:delete,inboxes:write,inboxes:read,automations:write,automations:delete,automations:read,teams:write,teams:read,users:write,users:read,dashboard_global:read,canned_responses:delete,tags:delete,canned_responses:write,tags:write,status:delete,status:write,status:read,oidc:delete,oidc:read,oidc:write,settings_notifications:read,settings_notifications:write,settings_general:write,templates:delete,admin:read}', 'Admin', 'Role for users who have access to the admin panel.');
VALUES('{conversations:read,conversations:read_unassigned,conversations:read_assigned,conversations:update_user_assignee,conversations:update_team_assignee,conversations:update_priority,conversations:update_status,conversations:update_tags,messages:read,messages:write}', 'Agent', 'Role for all agents with limited access to conversations.');
INSERT INTO roles
(permissions, "name", description)
VALUES('{conversations:read,conversations:read_unassigned,conversations:read_assigned,conversations:update_user_assignee,conversations:update_team_assignee,conversations:update_priority,conversations:update_status,conversations:update_tags,status:write,status:delete,tags:write,tags:delete,canned_responses:write,canned_responses:delete,dashboard:global,users:write,users:read,teams:read,teams:write,automations:read,automations:write,automations:delete,inboxes:read,inboxes:write,inboxes:delete,roles:read,roles:write,roles:delete,templates:read,templates:write,messages:read,messages:write,dashboard_global:read,oidc:delete,status:read,oidc:write,settings_notifications:read,oidc:read,settings_general:write,settings_notifications:write,conversations:read_all,templates:delete}', 'Agent', 'Role for all agents with limited access.');
VALUES('{conversations:read_unassigned,conversations:read_all,conversations:read,conversations:read_assigned,conversations:update_user_assignee,conversations:update_team_assignee,conversations:update_priority,conversations:update_status,conversations:update_tags,messages:read,messages:write,templates:write,templates:read,roles:delete,roles:write,roles:read,inboxes:delete,inboxes:write,inboxes:read,automations:write,automations:delete,automations:read,teams:write,teams:read,users:write,users:read,dashboard_global:read,canned_responses:delete,tags:delete,canned_responses:write,tags:write,status:delete,status:write,status:read,oidc:delete,oidc:read,oidc:write,settings_notifications:read,settings_notifications:write,settings_general:write,templates:delete,admin:read}', 'Admin', 'Role for users who have complete access to everything.');
DROP TABLE IF EXISTS settings CASCADE;
CREATE TABLE settings (