mirror of
https://github.com/abhinavxd/libredesk.git
synced 2025-11-02 04:53:41 +00:00
Merge pull request #105 from abhinavxd/feat/api-user
feat: API key management for agents
This commit is contained in:
17
cmd/ai.go
17
cmd/ai.go
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
19
cmd/login.go
19
cmd/login.go
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
41
cmd/teams.go
41
cmd/teams.go
@@ -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)
|
||||
|
||||
127
cmd/users.go
127
cmd/users.go
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
16
i18n/en.json
16
i18n/en.json
@@ -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.",
|
||||
|
||||
@@ -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:"-"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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}'
|
||||
);
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user