Merge pull request #105 from abhinavxd/feat/api-user

feat: API key management for agents
This commit is contained in:
Abhinav Raut
2025-06-19 02:05:00 +05:30
committed by GitHub
23 changed files with 869 additions and 179 deletions

View File

@@ -5,6 +5,11 @@ import (
"github.com/zerodha/fastglue"
)
type aiCompletionReq struct {
PromptKey string `json:"prompt_key"`
Content string `json:"content"`
}
type providerUpdateReq struct {
Provider string `json:"provider"`
APIKey string `json:"api_key"`
@@ -13,11 +18,15 @@ type providerUpdateReq struct {
// handleAICompletion handles AI completion requests
func handleAICompletion(r *fastglue.Request) error {
var (
app = r.Context.(*App)
promptKey = string(r.RequestCtx.PostArgs().Peek("prompt_key"))
content = string(r.RequestCtx.PostArgs().Peek("content"))
app = r.Context.(*App)
req = aiCompletionReq{}
)
resp, err := app.ai.Completion(promptKey, content)
if err := r.Decode(&req, "json"); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil))
}
resp, err := app.ai.Completion(req.PromptKey, req.Content)
if err != nil {
return sendErrorEnvelope(r, err)
}

View File

@@ -9,6 +9,10 @@ import (
"github.com/zerodha/fastglue"
)
type updateAutomationRuleExecutionModeReq struct {
Mode string `json:"mode"`
}
// handleGetAutomationRules gets all automation rules
func handleGetAutomationRules(r *fastglue.Request) error {
var (
@@ -118,14 +122,20 @@ func handleUpdateAutomationRuleWeights(r *fastglue.Request) error {
// handleUpdateAutomationRuleExecutionMode updates the execution mode of the automation rules for a given type
func handleUpdateAutomationRuleExecutionMode(r *fastglue.Request) error {
var (
app = r.Context.(*App)
mode = string(r.RequestCtx.PostArgs().Peek("mode"))
app = r.Context.(*App)
req = updateAutomationRuleExecutionModeReq{}
)
if mode != amodels.ExecutionModeAll && mode != amodels.ExecutionModeFirstMatch {
if err := r.Decode(&req, "json"); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil))
}
if req.Mode != amodels.ExecutionModeAll && req.Mode != amodels.ExecutionModeFirstMatch {
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 {
if err := app.automation.UpdateRuleExecutionMode(amodels.RuleTypeNewConversation, req.Mode); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)

View File

@@ -14,6 +14,14 @@ import (
"github.com/zerodha/fastglue"
)
type createContactNoteReq struct {
Note string `json:"note"`
}
type blockContactReq struct {
Enabled bool `json:"enabled"`
}
// handleGetContacts returns a list of contacts from the database.
func handleGetContacts(r *fastglue.Request) error {
var (
@@ -185,12 +193,17 @@ func handleCreateContactNote(r *fastglue.Request) error {
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"))
req = createContactNoteReq{}
)
if len(note) == 0 {
if err := r.Decode(&req, "json"); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil))
}
if len(req.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 {
if err := app.user.CreateNote(contactID, auser.ID, req.Note); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
@@ -238,12 +251,18 @@ 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")
req = blockContactReq{}
)
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 {
if err := r.Decode(&req, "json"); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil))
}
if err := app.user.ToggleEnabled(contactID, models.UserTypeContact, req.Enabled); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)

View File

@@ -1,7 +1,6 @@
package main
import (
"encoding/json"
"strconv"
"time"
@@ -19,16 +18,37 @@ import (
"github.com/zerodha/fastglue"
)
type assigneeChangeReq struct {
AssigneeID int `json:"assignee_id"`
}
type teamAssigneeChangeReq struct {
AssigneeID int `json:"assignee_id"`
}
type priorityUpdateReq struct {
Priority string `json:"priority"`
}
type statusUpdateReq struct {
Status string `json:"status"`
SnoozedUntil string `json:"snoozed_until,omitempty"`
}
type tagsUpdateReq struct {
Tags []string `json:"tags"`
}
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"`
InboxID int `json:"inbox_id"`
AssignedAgentID int `json:"agent_id"`
AssignedTeamID int `json:"team_id"`
Email string `json:"contact_email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Subject string `json:"subject"`
Content string `json:"content"`
Attachments []int `json:"attachments"`
}
// handleGetAllConversations retrieves all conversations.
@@ -304,13 +324,15 @@ func handleGetConversationParticipants(r *fastglue.Request) error {
// handleUpdateUserAssignee updates the user assigned to a conversation.
func handleUpdateUserAssignee(r *fastglue.Request) error {
var (
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
assigneeID = r.RequestCtx.PostArgs().GetUintOrZero("assignee_id")
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
req = assigneeChangeReq{}
)
if assigneeID == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`assignee_id`"), nil, envelope.InputError)
if err := r.Decode(&req, "json"); err != nil {
app.lo.Error("error decoding assignee change 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, "")
@@ -324,11 +346,11 @@ func handleUpdateUserAssignee(r *fastglue.Request) error {
}
// Already assigned?
if conversation.AssignedUserID.Int == assigneeID {
if conversation.AssignedUserID.Int == req.AssigneeID {
return r.SendEnvelope(true)
}
if err := app.conversation.UpdateConversationUserAssignee(uuid, assigneeID, user); err != nil {
if err := app.conversation.UpdateConversationUserAssignee(uuid, req.AssigneeID, user); err != nil {
return sendErrorEnvelope(r, err)
}
@@ -341,12 +363,16 @@ func handleUpdateTeamAssignee(r *fastglue.Request) error {
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
req = teamAssigneeChangeReq{}
)
assigneeID, err := r.RequestCtx.PostArgs().GetUint("assignee_id")
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`assignee_id`"), nil, envelope.InputError)
if err := r.Decode(&req, "json"); err != nil {
app.lo.Error("error decoding team assignee change request", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
assigneeID := req.AssigneeID
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
@@ -376,11 +402,18 @@ func handleUpdateTeamAssignee(r *fastglue.Request) error {
// handleUpdateConversationPriority updates the priority of a conversation.
func handleUpdateConversationPriority(r *fastglue.Request) error {
var (
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
priority = string(r.RequestCtx.PostArgs().Peek("priority"))
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
req = priorityUpdateReq{}
)
if err := r.Decode(&req, "json"); err != nil {
app.lo.Error("error decoding priority update request", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
priority := req.Priority
if priority == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`priority`"), nil, envelope.InputError)
}
@@ -403,13 +436,20 @@ func handleUpdateConversationPriority(r *fastglue.Request) error {
// handleUpdateConversationStatus updates the status of a conversation.
func handleUpdateConversationStatus(r *fastglue.Request) error {
var (
app = r.Context.(*App)
status = string(r.RequestCtx.PostArgs().Peek("status"))
snoozedUntil = string(r.RequestCtx.PostArgs().Peek("snoozed_until"))
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
req = statusUpdateReq{}
)
if err := r.Decode(&req, "json"); err != nil {
app.lo.Error("error decoding status update request", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
status := req.Status
snoozedUntil := req.SnoozedUntil
// Validate inputs
if status == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`status`"), nil, envelope.InputError)
@@ -463,18 +503,19 @@ func handleUpdateConversationStatus(r *fastglue.Request) error {
// handleUpdateConversationtags updates conversation tags.
func handleUpdateConversationtags(r *fastglue.Request) error {
var (
app = r.Context.(*App)
tagNames = []string{}
tagJSON = r.RequestCtx.PostArgs().Peek("tags")
auser = r.RequestCtx.UserValue("user").(amodels.User)
uuid = r.RequestCtx.UserValue("uuid").(string)
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
uuid = r.RequestCtx.UserValue("uuid").(string)
req = tagsUpdateReq{}
)
if err := json.Unmarshal(tagJSON, &tagNames); err != nil {
app.lo.Error("error unmarshalling tags JSON", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
if err := r.Decode(&req, "json"); err != nil {
app.lo.Error("error decoding tags update request", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
tagNames := req.Tags
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)

View File

@@ -15,7 +15,7 @@ import (
// initHandlers initializes the HTTP routes and handlers for the application.
func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
// Authentication.
g.POST("/api/v1/login", handleLogin)
g.POST("/api/v1/auth/login", handleLogin)
g.GET("/logout", auth(handleLogout))
g.GET("/api/v1/oidc/{id}/login", handleOIDCLogin)
g.GET("/api/v1/oidc/{id}/finish", handleOIDCCallback)
@@ -110,6 +110,8 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
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/{id}/api-key", perm(handleGenerateAPIKey, "users:manage"))
g.DELETE("/api/v1/agents/{id}/api-key", perm(handleRevokeAPIKey, "users:manage"))
g.POST("/api/v1/agents/reset-password", tryAuth(handleResetPassword))
g.POST("/api/v1/agents/set-password", tryAuth(handleSetPassword))

View File

@@ -9,17 +9,30 @@ import (
"github.com/zerodha/fastglue"
)
type loginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
// 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)
loginReq loginRequest
)
// Decode JSON request.
if err := r.Decode(&loginReq, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if loginReq.Email == "" || loginReq.Password == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.badRequest"), nil, envelope.InputError)
}
// Verify email and password.
user, err := app.user.VerifyPassword(email, password)
user, err := app.user.VerifyPassword(loginReq.Email, []byte(loginReq.Password))
if err != nil {
return sendErrorEnvelope(r, err)
}

View File

@@ -6,30 +6,80 @@ import (
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/user/models"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
"github.com/zerodha/simplesessions/v3"
)
// authenticateUser handles both API key and session-based authentication
// Returns the authenticated user or an error
// For session-based auth, CSRF is checked for POST/PUT/DELETE requests
func authenticateUser(r *fastglue.Request, app *App) (models.User, error) {
var user models.User
// Check for Authorization header first (API key authentication)
apiKey, apiSecret, err := r.ParseAuthHeader(fastglue.AuthBasic | fastglue.AuthToken)
if err == nil && len(apiKey) > 0 && len(apiSecret) > 0 {
user, err = app.user.ValidateAPIKey(string(apiKey), string(apiSecret))
if err != nil {
return user, err
}
return user, nil
}
// Session-based authentication - Check CSRF first.
method := string(r.RequestCtx.Method())
if method == "POST" || method == "PUT" || method == "DELETE" {
cookieToken := string(r.RequestCtx.Request.Header.Cookie("csrf_token"))
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", "method", method, "cookie_token", cookieToken, "header_token", hdrToken)
return user, envelope.NewError(envelope.PermissionError, app.i18n.T("auth.csrfTokenMismatch"), nil)
}
}
// 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 user, envelope.NewError(envelope.GeneralError, app.i18n.T("auth.invalidOrExpiredSession"), nil)
}
// Get agent user from cache or load it.
user, err = app.user.GetAgentCachedOrLoad(sessUser.ID)
if err != nil {
return user, err
}
// Destroy session if user is disabled.
if !user.Enabled {
if err := app.auth.DestroySession(r); err != nil {
app.lo.Error("error destroying session", "error", err)
}
return user, envelope.NewError(envelope.PermissionError, app.i18n.T("user.accountDisabled"), nil)
}
return user, nil
}
// 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.
// Supports both API key authentication (Authorization header) and session-based authentication.
func tryAuth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
return func(r *fastglue.Request) error {
app := r.Context.(*App)
// Try to validate session without returning error.
userSession, err := app.auth.ValidateSession(r)
if err != nil || userSession.ID <= 0 {
return handler(r)
}
// Try to get user.
user, err := app.user.GetAgentCachedOrLoad(userSession.ID)
// Try to authenticate user using shared authentication logic, but don't return errors
user, err := authenticateUser(r, app)
if err != nil {
// Authentication failed, but this is optional, so continue without user
return handler(r)
}
// Set user in context if found.
// Set user in context if authentication succeeded.
r.RequestCtx.SetUserValue("user", amodels.User{
ID: user.ID,
Email: user.Email.String,
@@ -41,23 +91,25 @@ func tryAuth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
}
}
// auth validates the session and adds the user to the request context.
// auth validates the session or API key and adds the user to the request context.
// Supports both API key authentication (Authorization header) and session-based authentication.
func auth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
return func(r *fastglue.Request) error {
var app = r.Context.(*App)
// Validate session and fetch user.
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, app.i18n.T("auth.invalidOrExpiredSession"), nil, envelope.GeneralError)
// Authenticate user using shared authentication logic
user, err := authenticateUser(r, app)
if err != nil {
if envErr, ok := err.(envelope.Error); ok {
if envErr.ErrorType == envelope.PermissionError {
return r.SendErrorEnvelope(http.StatusForbidden, envErr.Message, nil, envelope.PermissionError)
}
return r.SendErrorEnvelope(http.StatusUnauthorized, envErr.Message, nil, envelope.GeneralError)
}
return sendErrorEnvelope(r, err)
}
// Set user in the request context.
user, err := app.user.GetAgentCachedOrLoad(userSession.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
r.RequestCtx.SetUserValue("user", amodels.User{
ID: user.ID,
Email: user.Email.String,
@@ -69,41 +121,22 @@ func auth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
}
}
// 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.
// perm checks if the user has the required permission to access the endpoint.
// Supports both API key authentication (Authorization header) and session-based authentication.
func perm(handler fastglue.FastRequestHandler, perm string) fastglue.FastRequestHandler {
return func(r *fastglue.Request) error {
var (
app = r.Context.(*App)
cookieToken = string(r.RequestCtx.Request.Header.Cookie("csrf_token"))
hdrToken = string(r.RequestCtx.Request.Header.Peek("X-CSRFTOKEN"))
)
var app = r.Context.(*App)
// 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, 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, app.i18n.T("auth.invalidOrExpiredSession"), nil, envelope.GeneralError)
}
// Get agent user from cache or load it.
user, err := app.user.GetAgentCachedOrLoad(sessUser.ID)
// Authenticate user using shared authentication logic
user, err := authenticateUser(r, app)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Destroy session if user is disabled.
if !user.Enabled {
if err := app.auth.DestroySession(r); err != nil {
app.lo.Error("error destroying session", "error", err)
if envErr, ok := err.(envelope.Error); ok {
if envErr.ErrorType == envelope.PermissionError {
return r.SendErrorEnvelope(http.StatusForbidden, envErr.Message, nil, envelope.PermissionError)
}
return r.SendErrorEnvelope(http.StatusUnauthorized, envErr.Message, nil, envelope.GeneralError)
}
return r.SendErrorEnvelope(http.StatusUnauthorized, app.i18n.T("user.accountDisabled"), nil, envelope.PermissionError)
return sendErrorEnvelope(r, err)
}
// Split the permission string into object and action and enforce it.

View File

@@ -4,8 +4,8 @@ import (
"strconv"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/team/models"
"github.com/valyala/fasthttp"
"github.com/volatiletech/null/v9"
"github.com/zerodha/fastglue"
)
@@ -52,16 +52,15 @@ func handleGetTeam(r *fastglue.Request) error {
// handleCreateTeam creates a new team.
func handleCreateTeam(r *fastglue.Request) error {
var (
app = r.Context.(*App)
name = string(r.RequestCtx.PostArgs().Peek("name"))
timezone = string(r.RequestCtx.PostArgs().Peek("timezone"))
emoji = string(r.RequestCtx.PostArgs().Peek("emoji"))
conversationAssignmentType = string(r.RequestCtx.PostArgs().Peek("conversation_assignment_type"))
businessHrsID, _ = strconv.Atoi(string(r.RequestCtx.PostArgs().Peek("business_hours_id")))
slaPolicyID, _ = strconv.Atoi(string(r.RequestCtx.PostArgs().Peek("sla_policy_id")))
maxAutoAssignedConversations, _ = strconv.Atoi(string(r.RequestCtx.PostArgs().Peek("max_auto_assigned_conversations")))
app = r.Context.(*App)
req = models.Team{}
)
if err := app.team.Create(name, timezone, conversationAssignmentType, null.NewInt(businessHrsID, businessHrsID != 0), null.NewInt(slaPolicyID, slaPolicyID != 0), emoji, maxAutoAssignedConversations); err != nil {
if err := r.Decode(&req, "json"); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil))
}
if err := app.team.Create(req.Name, req.Timezone, req.ConversationAssignmentType, req.BusinessHoursID, req.SLAPolicyID, req.Emoji.String, req.MaxAutoAssignedConversations); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
@@ -70,20 +69,20 @@ func handleCreateTeam(r *fastglue.Request) error {
// handleUpdateTeam updates an existing team.
func handleUpdateTeam(r *fastglue.Request) error {
var (
app = r.Context.(*App)
name = string(r.RequestCtx.PostArgs().Peek("name"))
timezone = string(r.RequestCtx.PostArgs().Peek("timezone"))
emoji = string(r.RequestCtx.PostArgs().Peek("emoji"))
conversationAssignmentType = string(r.RequestCtx.PostArgs().Peek("conversation_assignment_type"))
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
businessHrsID, _ = strconv.Atoi(string(r.RequestCtx.PostArgs().Peek("business_hours_id")))
slaPolicyID, _ = strconv.Atoi(string(r.RequestCtx.PostArgs().Peek("sla_policy_id")))
maxAutoAssignedConversations, _ = strconv.Atoi(string(r.RequestCtx.PostArgs().Peek("max_auto_assigned_conversations")))
app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
req = models.Team{}
)
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)
}
if err := app.team.Update(id, name, timezone, conversationAssignmentType, null.NewInt(businessHrsID, businessHrsID != 0), null.NewInt(slaPolicyID, slaPolicyID != 0), emoji, maxAutoAssignedConversations); err != nil {
if err := r.Decode(&req, "json"); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil))
}
if err := app.team.Update(id, req.Name, req.Timezone, req.ConversationAssignmentType, req.BusinessHoursID, req.SLAPolicyID, req.Emoji.String, req.MaxAutoAssignedConversations); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)

View File

@@ -26,6 +26,29 @@ const (
maxAvatarSizeMB = 2
)
// Request structs for user-related endpoints
// UpdateAvailabilityRequest represents the request to update user availability
type UpdateAvailabilityRequest struct {
Status string `json:"status"`
}
// ResetPasswordRequest represents the password reset request
type ResetPasswordRequest struct {
Email string `json:"email"`
}
// SetPasswordRequest represents the set password request
type SetPasswordRequest struct {
Token string `json:"token"`
Password string `json:"password"`
}
// AvailabilityRequest represents the request to update agent availability
type AvailabilityRequest struct {
Status string `json:"status"`
}
// handleGetAgents returns all agents.
func handleGetAgents(r *fastglue.Request) error {
var (
@@ -67,29 +90,35 @@ func handleGetAgent(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)
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
ip = realip.FromRequest(r.RequestCtx)
availReq AvailabilityRequest
)
// Decode JSON request
if err := r.Decode(&availReq, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
agent, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
// Same status?
if agent.AvailabilityStatus == status {
if agent.AvailabilityStatus == availReq.Status {
return r.SendEnvelope(true)
}
// Update availability status.
if err := app.user.UpdateAvailability(auser.ID, status); err != nil {
if err := app.user.UpdateAvailability(auser.ID, availReq.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 {
if !(agent.AvailabilityStatus == models.Away && availReq.Status == models.Online) {
if err := app.activityLog.UserAvailability(auser.ID, auser.Email, availReq.Status, ip, "", 0); err != nil {
app.lo.Error("error creating activity log", "error", err)
}
}
@@ -351,19 +380,23 @@ func handleDeleteCurrentAgentAvatar(r *fastglue.Request) error {
func handleResetPassword(r *fastglue.Request) error {
var (
app = r.Context.(*App)
p = r.RequestCtx.PostArgs()
auser, ok = r.RequestCtx.UserValue("user").(amodels.User)
email = string(p.Peek("email"))
resetReq ResetPasswordRequest
)
if ok && auser.ID > 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("user.userAlreadyLoggedIn"), nil, envelope.InputError)
}
if email == "" {
// Decode JSON request
if err := r.Decode(&resetReq, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if resetReq.Email == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`email`"), nil, envelope.InputError)
}
agent, err := app.user.GetAgent(0, email)
agent, err := app.user.GetAgent(0, resetReq.Email)
if err != nil {
// Send 200 even if user not found, to prevent email enumeration.
return r.SendEnvelope("Reset password email sent successfully.")
@@ -401,20 +434,22 @@ func handleSetPassword(r *fastglue.Request) error {
var (
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"))
req = SetPasswordRequest{}
)
if ok && agent.ID > 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("user.userAlreadyLoggedIn"), nil, envelope.InputError)
}
if password == "" {
if err := r.Decode(&req, "json"); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil))
}
if req.Password == "" {
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 {
if err := app.user.ResetPassword(req.Token, req.Password); err != nil {
return sendErrorEnvelope(r, err)
}
@@ -484,3 +519,61 @@ func uploadUserAvatar(r *fastglue.Request, user *models.User, files []*multipart
}
return nil
}
// handleGenerateAPIKey generates a new API key for a user
func handleGenerateAPIKey(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)
}
// Check if user exists
user, err := app.user.GetAgent(id, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
// Generate API key and secret
apiKey, apiSecret, err := app.user.GenerateAPIKey(user.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Return the API key and secret (only shown once)
response := struct {
APIKey string `json:"api_key"`
APISecret string `json:"api_secret"`
}{
APIKey: apiKey,
APISecret: apiSecret,
}
return r.SendEnvelope(response)
}
// handleRevokeAPIKey revokes a user's API key
func handleRevokeAPIKey(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)
}
// Check if user exists
_, err := app.user.GetAgent(id, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
// Revoke API key
if err := app.user.RevokeAPIKey(id); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}

View File

@@ -38,7 +38,7 @@ describe('Login Component', () => {
it('should show error for invalid login attempt', () => {
// Mock failed login API call
cy.intercept('POST', '**/api/v1/login', {
cy.intercept('POST', '**/api/v1/auth/login', {
statusCode: 401,
body: {
message: 'Invalid credentials'
@@ -61,7 +61,7 @@ describe('Login Component', () => {
it('should login successfully with correct credentials', () => {
// Mock successful login API call
cy.intercept('POST', '**/api/v1/login', {
cy.intercept('POST', '**/api/v1/auth/login', {
statusCode: 200,
body: {
data: {
@@ -111,7 +111,7 @@ describe('Login Component', () => {
it('should show loading state during login', () => {
// Mock slow API response
cy.intercept('POST', '**/api/v1/login', {
cy.intercept('POST', '**/api/v1/auth/login', {
statusCode: 200,
body: {
data: {

View File

@@ -27,9 +27,13 @@ http.interceptors.request.use((request) => {
// 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'
request.headers['Content-Type'] = 'application/json'
}
if (request.headers['Content-Type'] === 'application/x-www-form-urlencoded') {
request.data = qs.stringify(request.data)
}
return request
})
@@ -135,7 +139,11 @@ const updateSettings = (key, data) =>
}
})
const getSettings = (key) => http.get(`/api/v1/settings/${key}`)
const login = (data) => http.post(`/api/v1/login`, data)
const login = (data) => http.post(`/api/v1/auth/login`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const getAutomationRules = (type) =>
http.get(`/api/v1/automations/rules`, {
params: { type: type }
@@ -162,7 +170,11 @@ const updateAutomationRuleWeights = (data) =>
}
})
const updateAutomationRulesExecutionMode = (data) =>
http.put(`/api/v1/automations/rules/execution-mode`, data)
http.put(`/api/v1/automations/rules/execution-mode`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const getRoles = () => http.get('/api/v1/roles')
const getRole = (id) => http.get(`/api/v1/roles/${id}`)
const createRole = (data) =>
@@ -186,11 +198,23 @@ const updateContact = (id, data) =>
'Content-Type': 'multipart/form-data'
}
})
const blockContact = (id, data) => http.put(`/api/v1/contacts/${id}/block`, data)
const blockContact = (id, data) => http.put(`/api/v1/contacts/${id}/block`, data, {
headers: {
'Content-Type': 'application/json'
}
})
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 updateTeam = (id, data) => http.put(`/api/v1/teams/${id}`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const createTeam = (data) => http.post('/api/v1/teams', data, {
headers: {
'Content-Type': 'application/json'
}
})
const getTeamsCompact = () => http.get('/api/v1/teams/compact')
const deleteTeam = (id) => http.delete(`/api/v1/teams/${id}`)
const updateUser = (id, data) =>
@@ -211,9 +235,21 @@ 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 updateCurrentUserAvailability = (data) => http.put('/api/v1/agents/me/availability', data, {
headers: {
'Content-Type': 'application/json'
}
})
const resetPassword = (data) => http.post('/api/v1/agents/reset-password', data, {
headers: {
'Content-Type': 'application/json'
}
})
const setPassword = (data) => http.post('/api/v1/agents/set-password', data, {
headers: {
'Content-Type': 'application/json'
}
})
const deleteUser = (id) => http.delete(`/api/v1/agents/${id}`)
const createUser = (data) =>
http.post('/api/v1/agents', data, {
@@ -222,9 +258,17 @@ const createUser = (data) =>
}
})
const getTags = () => http.get('/api/v1/tags')
const upsertTags = (uuid, data) => http.post(`/api/v1/conversations/${uuid}/tags`, data)
const upsertTags = (uuid, data) => http.post(`/api/v1/conversations/${uuid}/tags`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateAssignee = (uuid, assignee_type, data) =>
http.put(`/api/v1/conversations/${uuid}/assignee/${assignee_type}`, data)
http.put(`/api/v1/conversations/${uuid}/assignee/${assignee_type}`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const removeAssignee = (uuid, assignee_type) =>
http.put(`/api/v1/conversations/${uuid}/assignee/${assignee_type}/remove`)
const updateContactCustomAttribute = (uuid, data) =>
@@ -246,9 +290,17 @@ const createConversation = (data) =>
}
})
const updateConversationStatus = (uuid, data) =>
http.put(`/api/v1/conversations/${uuid}/status`, data)
http.put(`/api/v1/conversations/${uuid}/status`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateConversationPriority = (uuid, data) =>
http.put(`/api/v1/conversations/${uuid}/priority`, data)
http.put(`/api/v1/conversations/${uuid}/priority`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateAssigneeLastSeen = (uuid) => http.put(`/api/v1/conversations/${uuid}/last-seen`)
const getConversationMessage = (cuuid, uuid) =>
http.get(`/api/v1/conversations/${cuuid}/messages/${uuid}`)
@@ -334,10 +386,22 @@ const updateView = (id, data) =>
})
const deleteView = (id) => http.delete(`/api/v1/views/me/${id}`)
const getAiPrompts = () => http.get('/api/v1/ai/prompts')
const aiCompletion = (data) => http.post('/api/v1/ai/completion', data)
const updateAIProvider = (data) => http.put('/api/v1/ai/provider', data)
const aiCompletion = (data) => http.post('/api/v1/ai/completion', data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateAIProvider = (data) => http.put('/api/v1/ai/provider', data, {
headers: {
'Content-Type': 'application/json'
}
})
const getContactNotes = (id) => http.get(`/api/v1/contacts/${id}/notes`)
const createContactNote = (id, data) => http.post(`/api/v1/contacts/${id}/notes`, data)
const createContactNote = (id, data) => http.post(`/api/v1/contacts/${id}/notes`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const deleteContactNote = (id, noteId) => http.delete(`/api/v1/contacts/${id}/notes/${noteId}`)
const getActivityLogs = (params) => http.get('/api/v1/activity-logs', { params })
const getWebhooks = () => http.get('/api/v1/webhooks')
@@ -358,6 +422,15 @@ const deleteWebhook = (id) => http.delete(`/api/v1/webhooks/${id}`)
const toggleWebhook = (id) => http.put(`/api/v1/webhooks/${id}/toggle`)
const testWebhook = (id) => http.post(`/api/v1/webhooks/${id}/test`)
const generateAPIKey = (id) =>
http.post(`/api/v1/agents/${id}/api-key`, {}, {
headers: {
'Content-Type': 'application/json'
}
})
const revokeAPIKey = (id) => http.delete(`/api/v1/agents/${id}/api-key`)
export default {
login,
deleteUser,
@@ -492,5 +565,7 @@ export default {
updateWebhook,
deleteWebhook,
toggleWebhook,
testWebhook
testWebhook,
generateAPIKey,
revokeAPIKey
}

View File

@@ -52,6 +52,124 @@
</div>
</div>
<!-- API Key Management Section -->
<div class="bg-muted/30 box p-4 space-y-4" v-if="!isNewForm">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<p class="text-base font-semibold text-gray-900 dark:text-foreground">
{{ $t('globals.terms.apiKey', 2) }}
</p>
<p class="text-sm text-gray-500">
{{ $t('admin.agent.apiKey.description') }}
</p>
</div>
</div>
<!-- API Key Display -->
<div v-if="apiKeyData.api_key" class="space-y-3">
<div class="flex items-center justify-between p-3 bg-background border rounded-md">
<div class="flex items-center gap-3">
<Key class="w-4 h-4 text-gray-400" />
<div>
<p class="text-sm font-medium">{{ $t('globals.terms.apiKey') }}</p>
<p class="text-xs text-gray-500 font-mono">{{ apiKeyData.api_key }}</p>
</div>
</div>
<div class="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
@click="regenerateAPIKey"
:disabled="isAPIKeyLoading"
>
<RotateCcw class="w-4 h-4 mr-1" />
{{ $t('globals.messages.regenerate') }}
</Button>
<Button
type="button"
variant="destructive"
size="sm"
@click="revokeAPIKey"
:disabled="isAPIKeyLoading"
>
<Trash2 class="w-4 h-4 mr-1" />
{{ $t('globals.messages.revoke') }}
</Button>
</div>
</div>
<!-- Last Used Info -->
<div v-if="apiKeyLastUsedAt" class="text-xs text-gray-500">
{{ $t('globals.messages.lastUsed') }}:
{{ format(new Date(apiKeyLastUsedAt), 'PPpp') }}
</div>
</div>
<!-- No API Key State -->
<div v-else class="text-center py-6">
<Key class="w-8 h-8 text-gray-400 mx-auto mb-2" />
<p class="text-sm text-gray-500 mb-3">{{ $t('admin.agent.apiKey.noKey') }}</p>
<Button type="button" @click="generateAPIKey" :disabled="isAPIKeyLoading">
<Plus class="w-4 h-4 mr-1" />
{{ $t('globals.messages.generate', { name: $t('globals.terms.apiKey') }) }}
</Button>
</div>
</div>
<!-- API Key Display Dialog -->
<Dialog v-model:open="showAPIKeyDialog">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{{ $t('globals.messages.generated', { name: $t('globals.terms.apiKey') }) }}
</DialogTitle>
<DialogDescription> </DialogDescription>
</DialogHeader>
<div class="space-y-4">
<div>
<Label class="text-sm font-medium">{{ $t('globals.terms.apiKey') }}</Label>
<div class="flex items-center gap-2 mt-1">
<Input v-model="newAPIKeyData.api_key" readonly class="font-mono text-sm" />
<Button
type="button"
variant="outline"
size="sm"
@click="copyToClipboard(newAPIKeyData.api_key)"
>
<Copy class="w-4 h-4" />
</Button>
</div>
</div>
<div>
<Label class="text-sm font-medium">{{ $t('globals.terms.secret') }}</Label>
<div class="flex items-center gap-2 mt-1">
<Input v-model="newAPIKeyData.api_secret" readonly class="font-mono text-sm" />
<Button
type="button"
variant="outline"
size="sm"
@click="copyToClipboard(newAPIKeyData.api_secret)"
>
<Copy class="w-4 h-4" />
</Button>
</div>
</div>
<Alert>
<AlertTriangle class="h-4 w-4" />
<AlertTitle>{{ $t('globals.terms.warning') }}</AlertTitle>
<AlertDescription>
{{ $t('admin.agent.apiKey.warningMessage') }}
</AlertDescription>
</Alert>
</div>
<DialogFooter>
<Button @click="closeAPIKeyModal">{{ $t('globals.messages.close') }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- Form Fields -->
<FormField v-slot="{ field }" name="first_name">
<FormItem v-auto-animate>
@@ -194,7 +312,7 @@ import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import { vAutoAnimate } from '@formkit/auto-animate/vue'
import { Badge } from '@/components/ui/badge'
import { Clock, LogIn } from 'lucide-vue-next'
import { Clock, LogIn, Key, RotateCcw, Trash2, Plus, Copy, AlertTriangle } from 'lucide-vue-next'
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import {
@@ -207,7 +325,18 @@ import {
} from '@/components/ui/select'
import { SelectTag } from '@/components/ui/select'
import { Input } from '@/components/ui/input'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { useI18n } from 'vue-i18n'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { format } from 'date-fns'
import api from '@/api'
@@ -238,6 +367,19 @@ const props = defineProps({
const { t } = useI18n()
const teams = ref([])
const roles = ref([])
const emitter = useEmitter()
const apiKeyData = ref({
api_key: props.initialValues?.api_key || '',
api_secret: ''
})
const apiKeyLastUsedAt = ref(props.initialValues?.api_key_last_used_at || null)
const newAPIKeyData = ref({
api_key: '',
api_secret: ''
})
const showAPIKeyDialog = ref(false)
const isAPIKeyLoading = ref(false)
onMounted(async () => {
try {
@@ -245,7 +387,10 @@ onMounted(async () => {
teams.value = teamsResp.value.data.data
roles.value = rolesResp.value.data.data
} catch (err) {
console.log(err)
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: t('globals.messages.errorFetching')
})
}
})
@@ -284,6 +429,87 @@ const getInitials = (firstName, lastName) => {
return `${firstName.charAt(0).toUpperCase()}${lastName.charAt(0).toUpperCase()}`
}
const generateAPIKey = async () => {
if (!props.initialValues?.id) return
try {
isAPIKeyLoading.value = true
const response = await api.generateAPIKey(props.initialValues.id)
if (response.data) {
const responseData = response.data.data
newAPIKeyData.value = {
api_key: responseData.api_key,
api_secret: responseData.api_secret
}
apiKeyData.value.api_key = responseData.api_key
// Clear the last used timestamp since this is a new API key
apiKeyLastUsedAt.value = null
showAPIKeyDialog.value = true
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
description: t('globals.messages.generatedSuccessfully', {
name: t('globals.terms.apiKey')
})
})
}
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: t('globals.messages.errorGenerating', {
name: t('globals.terms.apiKey')
})
})
} finally {
isAPIKeyLoading.value = false
}
}
const regenerateAPIKey = async () => {
await generateAPIKey()
}
const revokeAPIKey = async () => {
if (!props.initialValues?.id) return
try {
isAPIKeyLoading.value = true
await api.revokeAPIKey(props.initialValues.id)
apiKeyData.value.api_key = ''
apiKeyData.value.api_secret = ''
apiKeyLastUsedAt.value = null
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
description: t('globals.messages.revokedSuccessfully', {
name: t('globals.terms.apiKey')
})
})
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: t('globals.messages.errorRevoking', {
name: t('globals.terms.apiKey')
})
})
} finally {
isAPIKeyLoading.value = false
}
}
const copyToClipboard = async (text) => {
try {
await navigator.clipboard.writeText(text)
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
description: t('globals.messages.copied')
})
} catch (error) {
console.error('Error copying to clipboard:', error)
}
}
const closeAPIKeyModal = () => {
showAPIKeyDialog.value = false
newAPIKeyData.value = { api_key: '', api_secret: '' }
}
watch(
() => props.initialValues,
(newValues) => {
@@ -302,6 +528,10 @@ watch(
'teams',
newValues.teams.map((team) => team.name)
)
// Update API key data
apiKeyData.value.api_key = newValues.api_key || ''
apiKeyLastUsedAt.value = newValues.api_key_last_used_at || null
}, 0)
}
},

View File

@@ -162,7 +162,7 @@ watch(
}
conversationStore.upsertTags({
tags: JSON.stringify(newTags)
tags: newTags
})
},
{ immediate: false }
@@ -184,13 +184,13 @@ const fetchTags = async () => {
const handleAssignedUserChange = (id) => {
conversationStore.updateAssignee('user', {
assignee_id: id
assignee_id: parseInt(id)
})
}
const handleAssignedTeamChange = (id) => {
conversationStore.updateAssignee('team', {
assignee_id: id
assignee_id: parseInt(id)
})
}

View File

@@ -245,9 +245,11 @@ export const useConversationStore = defineStore('conversation', () => {
if (!conv || !msgData || !inboxEmail) return
const latestMessage = msgData.getLatestMessage(conv.uuid, ['incoming', 'outgoing'], true)
if (!latestMessage) return
if (!["received", "sent"].includes(latestMessage.status)) {
if (!latestMessage) {
// Reset recipients if no latest message is found.
currentTo.value = []
currentCC.value = []
currentBCC.value = []
return
}

View File

@@ -160,6 +160,7 @@
"globals.terms.channel": "Channel",
"globals.terms.configure": "Configure",
"globals.terms.date": "Date",
"globals.terms.data": "Data | Datas",
"globals.terms.timestamp": "Timestamp | Timestamps",
"globals.terms.description": "Description | Descriptions",
"globals.terms.fromEmailAddress": "From email address | From email addresses",
@@ -186,6 +187,7 @@
"globals.terms.resolve": "Resolve",
"globals.terms.recipient": "Recipient | Recipients",
"globals.terms.tls": "TLS | TLSs",
"globals.terms.credential": "Credential | Credentials",
"globals.messages.invalid": "Invalid {name}",
"globals.messages.custom": "Custom {name}",
"globals.messages.replying": "Replying",
@@ -231,6 +233,14 @@
"globals.messages.blockedSuccessfully": "{name} blocked successfully",
"globals.messages.unblockedSuccessfully": "{name} unblocked successfully",
"globals.messages.sentSuccessfully": "{name} sent successfully",
"globals.messages.revokedSuccessfully": "{name} revoked successfully",
"globals.messages.errorRevoking": "Error revoking {name}",
"globals.messages.generatedSuccessfully": "{name} generated successfully",
"globals.messages.generate": "Generate {name}",
"globals.messages.generated": "{name} generated",
"globals.messages.regenerate": "Regenerate",
"globals.messages.revoke": "Revoke",
"globals.messages.lastUsed": "Last used",
"globals.messages.pageTooLarge": "Page size is too large, should be at most {max}",
"globals.messages.edit": "Edit {name}",
"globals.messages.delete": "Delete {name}",
@@ -246,6 +256,7 @@
"globals.messages.yes": "Yes {name}",
"globals.messages.no": "No {name}",
"globals.messages.select": "Select {name}",
"globals.messages.copied": "Copied to clipboard",
"globals.messages.search": "Search {name}",
"globals.messages.type": "{name} type",
"globals.messages.typeOf": "Type of {name}",
@@ -330,7 +341,7 @@
"csat.alreadySubmitted": "CSAT already submitted",
"auth.csrfTokenMismatch": "CSRF token mismatch",
"auth.invalidOrExpiredSession": "Invalid or expired session",
"auth.invalidOrExpiredSessionClearCookie": "Invalid or expired session, clear cookies and try again",
"auth.invalidOrExpiredSessionClearCookie": "Invalid or expired session. Please clear your cookies and try again.",
"auth.signIn": "Sign in to your account",
"auth.orContinueWith": "Or continue with",
"auth.enterEmail": "Enter your email",
@@ -453,6 +464,9 @@
"admin.inbox.configureChannel": "Configure channel",
"admin.inbox.createEmailInbox": "Create Email Inbox",
"admin.agent.deleteConfirmation": "This will permanently delete the agent. Consider disabling the account instead.",
"admin.agent.apiKey.description": "Generate API keys for this agent to access libredesk programmatically.",
"admin.agent.apiKey.noKey": "No API key has been generated for this agent.",
"admin.agent.apiKey.warningMessage": "This secret will only be shown once. Make sure to copy it now.",
"admin.role.roleForAllSupportAgents": "Role for all support agents",
"admin.role.setPermissionsForThisRole": "Set permissions for this role",
"admin.role.cannotModifyAdminRole": "Cannot modify admin role, Please create a new role.",

View File

@@ -23,5 +23,5 @@ type ActivityLog struct {
TargetModelID int `db:"target_model_id" json:"target_model_id"`
IP string `db:"ip" json:"ip"`
Total int `db:"total" json:"total"`
Total int `db:"total" json:"-"`
}

View File

@@ -63,5 +63,24 @@ func V0_7_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
return err
}
// Add API key authentication fields to users table
_, err = db.Exec(`
ALTER TABLE users
ADD COLUMN IF NOT EXISTS api_key TEXT NULL,
ADD COLUMN IF NOT EXISTS api_secret TEXT NULL,
ADD COLUMN IF NOT EXISTS api_key_last_used_at TIMESTAMPTZ NULL;
`)
if err != nil {
return err
}
// Create index for API key field
_, err = db.Exec(`
CREATE INDEX IF NOT EXISTS index_users_on_api_key ON users(api_key);
`)
if err != nil {
return err
}
return nil
}

View File

@@ -1,5 +1,5 @@
-- name: get-all
SELECT id, name, description FROM roles;
SELECT id, created_at, updated_at, name, description, permissions FROM roles;
-- name: get-role
SELECT * FROM roles where id = $1;

View File

@@ -23,7 +23,7 @@ WHERE id != $1 AND $4 = TRUE;
SELECT id, type, name, body, subject FROM templates WHERE is_default is TRUE;
-- name: get-all
SELECT id, type, name, is_default, updated_at FROM templates WHERE type = $1 ORDER BY updated_at DESC;
SELECT id, created_at, updated_at, type, name, is_default, is_builtin FROM templates WHERE type = $1 ORDER BY updated_at DESC;
-- name: get-template
SELECT id, type, name, body, subject, is_default, type FROM templates WHERE id = $1;

View File

@@ -21,10 +21,10 @@ const (
UserTypeContact = "contact"
// User availability statuses
Online = "online"
Offline = "offline"
Online = "online"
Offline = "offline"
// Away due to inactivity
Away = "away"
Away = "away"
// Away due to manual setting from sidebar
AwayManual = "away_manual"
AwayAndReassigning = "away_and_reassigning"
@@ -58,6 +58,11 @@ type User struct {
SourceChannel null.String `json:"-"`
SourceChannelID null.String `json:"-"`
// API Key fields
APIKey null.String `db:"api_key" json:"api_key"`
APIKeyLastUsedAt null.Time `db:"api_key_last_used_at" json:"api_key_last_used_at"`
APISecret null.String `db:"api_secret" json:"-"`
Total int `json:"total,omitempty"`
}

View File

@@ -48,6 +48,8 @@ SELECT
u.last_login_at,
u.phone_number_calling_code,
u.phone_number,
u.api_key,
u.api_key_last_used_at,
array_agg(DISTINCT r.name) FILTER (WHERE r.name IS NOT NULL) AS roles,
COALESCE(
(SELECT json_agg(json_build_object('id', t.id, 'name', t.name, 'emoji', t.emoji))
@@ -219,4 +221,52 @@ SELECT
u.avatar_url
FROM contact_notes cn
INNER JOIN users u ON u.id = cn.user_id
WHERE cn.id = $1;
WHERE cn.id = $1;
-- name: get-user-by-api-key
SELECT
u.id,
u.created_at,
u.updated_at,
u.email,
u.type,
u.enabled,
u.avatar_url,
u.first_name,
u.last_name,
u.availability_status,
u.last_active_at,
u.last_login_at,
u.phone_number_calling_code,
u.phone_number,
u.api_secret,
array_agg(DISTINCT r.name) FILTER (WHERE r.name IS NOT NULL) AS roles,
COALESCE(
(SELECT json_agg(json_build_object('id', t.id, 'name', t.name, 'emoji', t.emoji))
FROM team_members tm
JOIN teams t ON tm.team_id = t.id
WHERE tm.user_id = u.id),
'[]'
) AS teams,
array_agg(DISTINCT p ORDER BY p) FILTER (WHERE p IS NOT NULL) AS permissions
FROM users u
LEFT JOIN user_roles ur ON ur.user_id = u.id
LEFT JOIN roles r ON r.id = ur.role_id
LEFT JOIN LATERAL unnest(r.permissions) AS p ON true
WHERE u.api_key = $1 AND u.enabled = true AND u.deleted_at IS NULL
GROUP BY u.id;
-- name: generate-api-key
UPDATE users
SET api_key = $2, api_secret = $3, api_key_last_used_at = NULL, updated_at = now()
WHERE id = $1;
-- name: revoke-api-key
UPDATE users
SET api_key = NULL, api_secret = NULL, api_key_last_used_at = NULL, updated_at = now()
WHERE id = $1;
-- name: update-api-key-last-used
UPDATE users
SET api_key_last_used_at = now()
WHERE id = $1;

View File

@@ -82,6 +82,11 @@ type queries struct {
InsertContact *sqlx.Stmt `query:"insert-contact"`
InsertNote *sqlx.Stmt `query:"insert-note"`
ToggleEnable *sqlx.Stmt `query:"toggle-enable"`
// API key queries
GetUserByAPIKey *sqlx.Stmt `query:"get-user-by-api-key"`
GenerateAPIKey *sqlx.Stmt `query:"generate-api-key"`
RevokeAPIKey *sqlx.Stmt `query:"revoke-api-key"`
UpdateAPIKeyLastUsed *sqlx.Stmt `query:"update-api-key-last-used"`
}
// New creates and returns a new instance of the Manager.
@@ -297,6 +302,72 @@ func (u *Manager) ToggleEnabled(id int, typ string, enabled bool) error {
return nil
}
// GenerateAPIKey generates a new API key and secret for a user
func (u *Manager) GenerateAPIKey(userID int) (string, string, error) {
// Generate API key (32 characters)
apiKey, err := stringutil.RandomAlphanumeric(32)
if err != nil {
u.lo.Error("error generating API key", "error", err, "user_id", userID)
return "", "", envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorGenerating", "name", "{globals.terms.apiKey}"), nil)
}
// Generate API secret (64 characters)
apiSecret, err := stringutil.RandomAlphanumeric(64)
if err != nil {
u.lo.Error("error generating API secret", "error", err, "user_id", userID)
return "", "", envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorGenerating", "name", "{globals.terms.apiKey}"), nil)
}
// Hash the API secret for storage
secretHash, err := bcrypt.GenerateFromPassword([]byte(apiSecret), bcrypt.DefaultCost)
if err != nil {
u.lo.Error("error hashing API secret", "error", err, "user_id", userID)
return "", "", envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorGenerating", "name", "{globals.terms.apiKey}"), nil)
}
// Update user with API key.
if _, err := u.q.GenerateAPIKey.Exec(userID, apiKey, string(secretHash)); err != nil {
u.lo.Error("error saving API key", "error", err, "user_id", userID)
return "", "", envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorGenerating", "name", "{globals.terms.apiKey}"), nil)
}
return apiKey, apiSecret, nil
}
// ValidateAPIKey validates API key and secret and returns the user
func (u *Manager) ValidateAPIKey(apiKey, apiSecret string) (models.User, error) {
var user models.User
// Find user by API key.
if err := u.q.GetUserByAPIKey.Get(&user, apiKey); err != nil {
if err == sql.ErrNoRows {
return user, envelope.NewError(envelope.UnauthorizedError, u.i18n.Ts("globals.messages.invalid", "name", u.i18n.P("globals.terms.credential")), nil)
}
return user, envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.user}"), nil)
}
// Verify API secret.
if err := bcrypt.CompareHashAndPassword([]byte(user.APISecret.String), []byte(apiSecret)); err != nil {
return user, envelope.NewError(envelope.UnauthorizedError, u.i18n.Ts("globals.messages.invalid", "name", u.i18n.T("globals.terms.credential")), nil)
}
// Update last used timestamp.
if _, err := u.q.UpdateAPIKeyLastUsed.Exec(user.ID); err != nil {
u.lo.Error("failed to update API key last used timestamp", "error", err, "user_id", user.ID)
}
return user, nil
}
// RevokeAPIKey deactivates the API key for a user
func (u *Manager) RevokeAPIKey(userID int) error {
if _, err := u.q.RevokeAPIKey.Exec(userID); err != nil {
u.lo.Error("error revoking API key", "error", err, "user_id", userID)
return envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorRevoking", "name", "{globals.terms.apiKey}"), nil)
}
return nil
}
// ChangeSystemUserPassword updates the system user's password with a newly prompted one.
func ChangeSystemUserPassword(ctx context.Context, db *sqlx.DB) error {
// Prompt for password and get hashed password

View File

@@ -140,6 +140,10 @@ CREATE TABLE users (
availability_status user_availability_status DEFAULT 'offline' NOT NULL,
last_active_at TIMESTAMPTZ NULL,
last_login_at TIMESTAMPTZ NULL,
-- API key authentication fields
api_key TEXT NULL,
api_secret TEXT NULL,
api_key_last_used_at TIMESTAMPTZ NULL,
CONSTRAINT constraint_users_on_country CHECK (LENGTH(country) <= 140),
CONSTRAINT constraint_users_on_phone_number CHECK (LENGTH(phone_number) <= 20),
CONSTRAINT constraint_users_on_phone_number_calling_code CHECK (LENGTH(phone_number_calling_code) <= 10),
@@ -150,6 +154,7 @@ CREATE TABLE users (
CREATE UNIQUE INDEX index_unique_users_on_email_and_type_when_deleted_at_is_null ON users (email, type)
WHERE deleted_at IS NULL;
CREATE INDEX index_tgrm_users_on_email ON users USING GIN (email gin_trgm_ops);
CREATE INDEX index_users_on_api_key ON users(api_key);
DROP TABLE IF EXISTS user_roles CASCADE;
CREATE TABLE user_roles (
@@ -664,7 +669,7 @@ VALUES
(
'Admin',
'Role for users who have complete access to everything.',
'{activity_logs:manage,custom_attributes:manage,contacts:read_all,contacts:read,contacts:write,contacts:block,contact_notes:read,contact_notes:write,contact_notes:delete,conversations:write,ai:manage,general_settings:manage,notification_settings:manage,oidc:manage,conversations:read_all,conversations:read_unassigned,conversations:read_assigned,conversations:read_team_inbox,conversations:read,conversations:update_user_assignee,conversations:update_team_assignee,conversations:update_priority,conversations:update_status,conversations:update_tags,messages:read,messages:write,view:manage,status:manage,tags:manage,macros:manage,users:manage,teams:manage,automations:manage,inboxes:manage,roles:manage,reports:manage,templates:manage,business_hours:manage,sla:manage}'
'{webhooks:manage,activity_logs:manage,custom_attributes:manage,contacts:read_all,contacts:read,contacts:write,contacts:block,contact_notes:read,contact_notes:write,contact_notes:delete,conversations:write,ai:manage,general_settings:manage,notification_settings:manage,oidc:manage,conversations:read_all,conversations:read_unassigned,conversations:read_assigned,conversations:read_team_inbox,conversations:read,conversations:update_user_assignee,conversations:update_team_assignee,conversations:update_priority,conversations:update_status,conversations:update_tags,messages:read,messages:write,view:manage,status:manage,tags:manage,macros:manage,users:manage,teams:manage,automations:manage,inboxes:manage,roles:manage,reports:manage,templates:manage,business_hours:manage,sla:manage}'
);