feat: API key management for agents, api keys can now be generated for any agent in libredesk allowing programmatic access.

- Added endpoints to generate and revoke API keys for agents.
- Updated user model to include API key fields.
- Update authentication middleware to support API key validation.
- Modified database schema to accommodate API key fields.
- Updated frontend to manage API keys, including generation and revocation.
- Added localization strings for API key related messages.
This commit is contained in:
Abhinav Raut
2025-06-16 23:45:00 +05:30
parent 1879d9d22b
commit c37258fccb
12 changed files with 628 additions and 76 deletions

View File

@@ -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,26 @@ import (
"github.com/zerodha/fastglue"
)
type loginRequest struct {
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required"`
}
// 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)
}
// 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,77 @@ 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
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 {
// API key authentication
user, err = app.user.ValidateAPIKey(string(apiKey), string(apiSecret))
if err != nil {
return user, err
}
return user, nil
}
// Session-based authentication
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", "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 +88,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 +118,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

@@ -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.")
@@ -484,3 +517,63 @@ 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, err = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
// 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(map[string]string{
"message": app.i18n.Ts("globals.messages.revokedSuccessfully", "name", app.i18n.T("globals.terms.apiKey")),
})
}

View File

@@ -135,7 +135,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/login`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const getAutomationRules = (type) =>
http.get(`/api/v1/automations/rules`, {
params: { type: type }
@@ -211,9 +215,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, {
@@ -358,6 +374,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 +517,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

@@ -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

@@ -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

@@ -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))
@@ -220,3 +222,51 @@ SELECT
FROM contact_notes cn
INNER JOIN users u ON u.id = cn.user_id
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.
@@ -398,3 +403,66 @@ func updateSystemUserPassword(db *sqlx.DB, hashedPassword []byte) 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 {
return "", "", fmt.Errorf("failed to generate API key: %v", err)
}
// Generate API secret (64 characters)
apiSecret, err := stringutil.RandomAlphanumeric(64)
if err != nil {
return "", "", fmt.Errorf("failed to generate API secret: %v", err)
}
// Hash the API secret for storage
secretHash, err := bcrypt.GenerateFromPassword([]byte(apiSecret), bcrypt.DefaultCost)
if err != nil {
return "", "", fmt.Errorf("failed to hash API secret: %v", err)
}
// Update user with API key.
if _, err := u.q.GenerateAPIKey.Exec(userID, apiKey, string(secretHash)); err != nil {
return "", "", fmt.Errorf("failed to generate API key: %v", err)
}
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.
err := u.q.GetUserByAPIKey.Get(&user, apiKey)
if err != nil {
if err == sql.ErrNoRows {
return user, envelope.NewError(envelope.UnauthorizedError, u.i18n.Ts("globals.messages.invalid", "name", u.i18n.T("globals.terms.credential")), nil)
}
return user, fmt.Errorf("fetching error by api key: %v", err)
}
// 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 {
return fmt.Errorf("failed to revoke API key: %v", err)
}
return nil
}

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) WHERE api_key;
DROP TABLE IF EXISTS user_roles CASCADE;
CREATE TABLE user_roles (