From db3e02760eb5f14487360cc46e0fa5a3371e721c Mon Sep 17 00:00:00 2001 From: Gumbraise <34947108+Gumbraise@users.noreply.github.com> Date: Wed, 13 May 2026 23:29:47 +0200 Subject: [PATCH 001/178] feat(contact): add insert-contact-manual SQL query --- internal/user/queries.sql | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/user/queries.sql b/internal/user/queries.sql index 67a04774..872b07da 100644 --- a/internal/user/queries.sql +++ b/internal/user/queries.sql @@ -209,6 +209,11 @@ UPDATE users SET enabled = $3, updated_at = NOW() WHERE id = $1 AND type = $2; +-- name: insert-contact-manual +INSERT INTO users (email, type, first_name, last_name, password, avatar_url, phone_number, phone_number_country_code, country, custom_attributes) +VALUES ($1, 'contact', $2, $3, $4, $5, $6, $7, $8, '{}') +RETURNING id; + -- name: update-contact UPDATE users SET first_name = COALESCE($2, first_name), From 7e9158684787aaa95967c3074ba71398b364b7d8 Mon Sep 17 00:00:00 2001 From: Gumbraise <34947108+Gumbraise@users.noreply.github.com> Date: Wed, 13 May 2026 23:34:22 +0200 Subject: [PATCH 002/178] feat(contact): register InsertContactManual prepared statement --- internal/user/user.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/user/user.go b/internal/user/user.go index a863e56e..f54d9841 100644 --- a/internal/user/user.go +++ b/internal/user/user.go @@ -85,6 +85,7 @@ type queries struct { InsertAgent *sqlx.Stmt `query:"insert-agent"` InsertContactWithExtID *sqlx.Stmt `query:"insert-contact-with-external-id"` InsertContactNoExtID *sqlx.Stmt `query:"insert-contact-without-external-id"` + InsertContactManual *sqlx.Stmt `query:"insert-contact-manual"` GetContactByEmail *sqlx.Stmt `query:"get-contact-by-email"` GetContactByEmailWithoutExtID *sqlx.Stmt `query:"get-contact-by-email-without-ext-id"` IsEmailBlocked *sqlx.Stmt `query:"is-email-blocked"` From 8c82704558710d57328a50addf3ae217508f3d32 Mon Sep 17 00:00:00 2001 From: Gumbraise <34947108+Gumbraise@users.noreply.github.com> Date: Wed, 13 May 2026 23:39:11 +0200 Subject: [PATCH 003/178] feat(contact): add CreateManualContact method --- internal/user/contact.go | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/internal/user/contact.go b/internal/user/contact.go index ed992042..ae8f2b15 100644 --- a/internal/user/contact.go +++ b/internal/user/contact.go @@ -10,6 +10,30 @@ import ( "github.com/volatiletech/null/v9" ) +// CreateManualContact creates a new contact with all optional fields from the management UI. +func (u *Manager) CreateManualContact(user *models.User) error { + password, err := u.generatePassword() + if err != nil { + u.lo.Error("generating password", "error", err) + return fmt.Errorf("generating password: %w", err) + } + + // Normalize email. + user.Email = null.NewString(strings.ToLower(strings.TrimSpace(user.Email.String)), user.Email.Valid) + + if err := u.q.InsertContactManual.QueryRow( + user.Email, user.FirstName, user.LastName, password, user.AvatarURL, + user.PhoneNumber, user.PhoneNumberCountryCode, user.Country, + ).Scan(&user.ID); err != nil { + if dbutil.IsUniqueViolationError(err) { + return envelope.NewError(envelope.InputError, u.i18n.T("contact.alreadyExistsWithEmail"), nil) + } + u.lo.Error("error creating manual contact", "error", err) + return envelope.NewError(envelope.GeneralError, u.i18n.T("globals.messages.somethingWentWrong"), nil) + } + return nil +} + func (u *Manager) CreateContact(user *models.User) error { password, err := u.generatePassword() if err != nil { From 9bc84b22343e8ed00ea67dfca723ddb638cfd8e5 Mon Sep 17 00:00:00 2001 From: Gumbraise <34947108+Gumbraise@users.noreply.github.com> Date: Wed, 13 May 2026 23:45:33 +0200 Subject: [PATCH 004/178] feat(contact): add handleCreateContact handler --- cmd/contacts.go | 84 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/cmd/contacts.go b/cmd/contacts.go index 4a2c237e..8ace81c2 100644 --- a/cmd/contacts.go +++ b/cmd/contacts.go @@ -22,6 +22,90 @@ type blockContactReq struct { Enabled bool `json:"enabled"` } +// handleCreateContact creates a new contact. +func handleCreateContact(r *fastglue.Request) error { + var app = r.Context.(*App) + + form, err := r.RequestCtx.MultipartForm() + if err != nil { + app.lo.Error("error parsing form data", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("errors.parsingRequest"), nil, envelope.GeneralError) + } + + firstName := "" + if v, ok := form.Value["first_name"]; ok && len(v) > 0 { + firstName = string(v[0]) + } + lastName := "" + if v, ok := form.Value["last_name"]; ok && len(v) > 0 { + lastName = string(v[0]) + } + email := "" + if v, ok := form.Value["email"]; ok && len(v) > 0 { + email = strings.TrimSpace(string(v[0])) + } + phoneNumber := "" + if v, ok := form.Value["phone_number"]; ok && len(v) > 0 { + phoneNumber = string(v[0]) + } + phoneNumberCountryCode := "" + if v, ok := form.Value["phone_number_country_code"]; ok && len(v) > 0 { + phoneNumberCountryCode = string(v[0]) + } + country := "" + if v, ok := form.Value["country"]; ok && len(v) > 0 { + country = string(v[0]) + } + + // Nullify "null" strings. + if phoneNumberCountryCode == "null" { + phoneNumberCountryCode = "" + } + if phoneNumber == "null" { + phoneNumber = "" + } + if country == "null" { + country = "" + } + + // Validate email format if provided. + if email != "" && !stringutil.ValidEmail(email) { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("validation.invalidEmail"), nil, envelope.InputError) + } + + contact := models.User{ + FirstName: firstName, + LastName: lastName, + Email: null.NewString(email, email != ""), + PhoneNumber: null.NewString(phoneNumber, phoneNumber != ""), + PhoneNumberCountryCode: null.NewString(phoneNumberCountryCode, phoneNumberCountryCode != ""), + Country: null.NewString(country, country != ""), + } + + if err := app.user.CreateManualContact(&contact); err != nil { + return sendErrorEnvelope(r, err) + } + + createdContact, err := app.user.GetContactOrVisitor(contact.ID, "") + if err != nil { + return sendErrorEnvelope(r, err) + } + + // Upload avatar if provided. + files, ok := form.File["files"] + if ok && len(files) > 0 { + if err := uploadUserAvatar(r, createdContact, files); err != nil { + return sendErrorEnvelope(r, err) + } + createdContact, err = app.user.GetContactOrVisitor(contact.ID, "") + if err != nil { + return sendErrorEnvelope(r, err) + } + } + + return r.SendEnvelope(createdContact) +} + // handleGetContacts returns a list of contacts from the database. func handleGetContacts(r *fastglue.Request) error { var ( From 1f59bff183e976e60b6670f5899979363bdf0b9a Mon Sep 17 00:00:00 2001 From: Gumbraise <34947108+Gumbraise@users.noreply.github.com> Date: Wed, 13 May 2026 23:52:18 +0200 Subject: [PATCH 005/178] feat(contact): register POST /api/v1/contacts route --- cmd/handlers.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cmd/handlers.go b/cmd/handlers.go index 625ba272..ced42755 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -143,6 +143,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) { // Contacts. g.GET("/api/v1/contacts", perm(handleGetContacts, "contacts:read_all")) + g.POST("/api/v1/contacts", perm(handleCreateContact, "contacts:write")) g.GET("/api/v1/contacts/{id}", perm(handleGetContact, "contacts:read")) g.PUT("/api/v1/contacts/{id}", perm(handleUpdateContact, "contacts:write")) g.PUT("/api/v1/contacts/{id}/block", perm(handleBlockContact, "contacts:block")) From 9999c1a3ab4893a0dc122e3b09970a67227a2702 Mon Sep 17 00:00:00 2001 From: Gumbraise <34947108+Gumbraise@users.noreply.github.com> Date: Wed, 13 May 2026 23:57:44 +0200 Subject: [PATCH 006/178] feat(i18n): add create contact translation keys --- i18n/en.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/i18n/en.json b/i18n/en.json index 55d3723a..35665807 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -451,6 +451,9 @@ "contact.identityNotVerified": "Identity not verified", "contact.identityVerified": "Identity verified", "contact.newNote": "New note", + "contact.newContact": "New contact", + "contact.createContact": "Create contact", + "contact.createdSuccessfully": "Contact created successfully", "contact.noContactsFound": "No contacts found", "contact.notes.empty": "No notes yet", "contact.notes.help": "Add note for this contact to keep track of important information and conversations.", From 3ed34009550ce88c8cb6dd79f68edae5d51587f5 Mon Sep 17 00:00:00 2001 From: Gumbraise <34947108+Gumbraise@users.noreply.github.com> Date: Thu, 14 May 2026 00:04:09 +0200 Subject: [PATCH 007/178] feat(contact): add createContact API function --- frontend/apps/main/src/api/index.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/frontend/apps/main/src/api/index.js b/frontend/apps/main/src/api/index.js index d8638db6..b57024c6 100644 --- a/frontend/apps/main/src/api/index.js +++ b/frontend/apps/main/src/api/index.js @@ -192,6 +192,12 @@ const updateRole = (id, data) => const deleteRole = (id) => http.delete(`/api/v1/roles/${id}`) const getContacts = (params) => http.get('/api/v1/contacts', { params }) const getContact = (id) => http.get(`/api/v1/contacts/${id}`) +const createContact = (data) => + http.post('/api/v1/contacts', data, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }) const updateContact = (id, data) => http.put(`/api/v1/contacts/${id}`, data, { headers: { @@ -648,6 +654,7 @@ export default { removeAssignee, getContacts, getContact, + createContact, updateContact, blockContact, getCustomAttributes, From 72c794f1b1d6016e923ec287ffd6a9a687ae60ed Mon Sep 17 00:00:00 2001 From: Gumbraise <34947108+Gumbraise@users.noreply.github.com> Date: Thu, 14 May 2026 00:12:55 +0200 Subject: [PATCH 008/178] feat(contact): add createContactFormSchema with all optional fields --- .../main/src/features/contact/formSchema.js | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/frontend/apps/main/src/features/contact/formSchema.js b/frontend/apps/main/src/features/contact/formSchema.js index c216b67e..8765806d 100644 --- a/frontend/apps/main/src/features/contact/formSchema.js +++ b/frontend/apps/main/src/features/contact/formSchema.js @@ -40,3 +40,34 @@ export const createFormSchema = (t) => z.object({ message: t('validation.invalidEmail'), }), }) + +export const createContactFormSchema = (t) => z.object({ + first_name: z + .string() + .max(50, { + message: t('validation.minmax', { min: 0, max: 50 }) + }) + .optional() + .or(z.literal('')), + last_name: z + .string() + .max(50, { + message: t('validation.minmax', { min: 0, max: 50 }) + }) + .optional() + .or(z.literal('')), + email: z + .string() + .email({ message: t('validation.invalidEmail') }) + .optional() + .or(z.literal('')), + phone_number: z + .string() + .refine(val => !val || /^\d{1,15}$/.test(val), { + message: t('validation.minmax', { min: 1, max: 15 }) + }) + .optional() + .or(z.literal('')), + phone_number_country_code: z.string().optional().nullable(), + country: z.string().optional().nullable(), +}) From cc69a5b2c0655cf33811e65cddd17ee1f12d8c44 Mon Sep 17 00:00:00 2001 From: Gumbraise <34947108+Gumbraise@users.noreply.github.com> Date: Thu, 14 May 2026 00:23:31 +0200 Subject: [PATCH 009/178] feat(contact): add CreateContactDialog component --- .../features/contact/CreateContactDialog.vue | 251 ++++++++++++++++++ 1 file changed, 251 insertions(+) create mode 100644 frontend/apps/main/src/features/contact/CreateContactDialog.vue diff --git a/frontend/apps/main/src/features/contact/CreateContactDialog.vue b/frontend/apps/main/src/features/contact/CreateContactDialog.vue new file mode 100644 index 00000000..003adbf9 --- /dev/null +++ b/frontend/apps/main/src/features/contact/CreateContactDialog.vue @@ -0,0 +1,251 @@ + + + From 989adbc4823c7e3a725a92d4f9d623ade33e0ce2 Mon Sep 17 00:00:00 2001 From: Gumbraise <34947108+Gumbraise@users.noreply.github.com> Date: Thu, 14 May 2026 00:38:14 +0200 Subject: [PATCH 010/178] feat(contact): add create contact button and dialog to contacts list --- .../src/features/contact/ContactsList.vue | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/frontend/apps/main/src/features/contact/ContactsList.vue b/frontend/apps/main/src/features/contact/ContactsList.vue index a5c1907a..ab0c0a12 100644 --- a/frontend/apps/main/src/features/contact/ContactsList.vue +++ b/frontend/apps/main/src/features/contact/ContactsList.vue @@ -42,6 +42,17 @@ + + + @@ -104,10 +115,16 @@ :total-pages="totalPages" /> + + From 09945379be8e95f79e04c316fd44f987e0826fe3 Mon Sep 17 00:00:00 2001 From: Gumbraise <34947108+Gumbraise@users.noreply.github.com> Date: Thu, 14 May 2026 01:43:48 +0200 Subject: [PATCH 012/178] fix(contact): return envelope error --- internal/user/contact.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/user/contact.go b/internal/user/contact.go index ae8f2b15..325849f9 100644 --- a/internal/user/contact.go +++ b/internal/user/contact.go @@ -15,7 +15,7 @@ func (u *Manager) CreateManualContact(user *models.User) error { password, err := u.generatePassword() if err != nil { u.lo.Error("generating password", "error", err) - return fmt.Errorf("generating password: %w", err) + return envelope.NewError(envelope.GeneralError, u.i18n.T("globals.messages.somethingWentWrong"), nil) } // Normalize email. From 46368da80f8d43bb06145ceab1e266d3b5f9d249 Mon Sep 17 00:00:00 2001 From: Gumbraise <34947108+Gumbraise@users.noreply.github.com> Date: Thu, 14 May 2026 01:45:27 +0200 Subject: [PATCH 013/178] fix(contact): revoke avatar preview URLs --- .../src/features/contact/CreateContactDialog.vue | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/frontend/apps/main/src/features/contact/CreateContactDialog.vue b/frontend/apps/main/src/features/contact/CreateContactDialog.vue index 003adbf9..8ae71a17 100644 --- a/frontend/apps/main/src/features/contact/CreateContactDialog.vue +++ b/frontend/apps/main/src/features/contact/CreateContactDialog.vue @@ -135,7 +135,7 @@ From f9d4089590cec8bd2aa4882793888f01bb3c3d27 Mon Sep 17 00:00:00 2001 From: Gumbraise <34947108+Gumbraise@users.noreply.github.com> Date: Thu, 14 May 2026 01:52:26 +0200 Subject: [PATCH 015/178] refactor(contact): share form parsing --- cmd/contacts.go | 152 ++++++++++++++++++------------------------------ 1 file changed, 56 insertions(+), 96 deletions(-) diff --git a/cmd/contacts.go b/cmd/contacts.go index 8ace81c2..1bad4d15 100644 --- a/cmd/contacts.go +++ b/cmd/contacts.go @@ -22,6 +22,42 @@ type blockContactReq struct { Enabled bool `json:"enabled"` } +type contactFormValues struct { + FirstName string + LastName string + Email string + PhoneNumber string + PhoneNumberCountryCode string + Country string + AvatarURL string +} + +func parseContactFormValues(values map[string][]string) contactFormValues { + return contactFormValues{ + FirstName: getContactFormValue(values, "first_name"), + LastName: getContactFormValue(values, "last_name"), + Email: strings.TrimSpace(getContactFormValue(values, "email")), + PhoneNumber: nullifyContactFormValue(getContactFormValue(values, "phone_number")), + PhoneNumberCountryCode: nullifyContactFormValue(getContactFormValue(values, "phone_number_country_code")), + Country: nullifyContactFormValue(getContactFormValue(values, "country")), + AvatarURL: nullifyContactFormValue(getContactFormValue(values, "avatar_url")), + } +} + +func getContactFormValue(values map[string][]string, key string) string { + if v, ok := values[key]; ok && len(v) > 0 { + return v[0] + } + return "" +} + +func nullifyContactFormValue(value string) string { + if value == "null" { + return "" + } + return value +} + // handleCreateContact creates a new contact. func handleCreateContact(r *fastglue.Request) error { var app = r.Context.(*App) @@ -32,54 +68,20 @@ func handleCreateContact(r *fastglue.Request) error { return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("errors.parsingRequest"), nil, envelope.GeneralError) } - firstName := "" - if v, ok := form.Value["first_name"]; ok && len(v) > 0 { - firstName = string(v[0]) - } - lastName := "" - if v, ok := form.Value["last_name"]; ok && len(v) > 0 { - lastName = string(v[0]) - } - email := "" - if v, ok := form.Value["email"]; ok && len(v) > 0 { - email = strings.TrimSpace(string(v[0])) - } - phoneNumber := "" - if v, ok := form.Value["phone_number"]; ok && len(v) > 0 { - phoneNumber = string(v[0]) - } - phoneNumberCountryCode := "" - if v, ok := form.Value["phone_number_country_code"]; ok && len(v) > 0 { - phoneNumberCountryCode = string(v[0]) - } - country := "" - if v, ok := form.Value["country"]; ok && len(v) > 0 { - country = string(v[0]) - } - - // Nullify "null" strings. - if phoneNumberCountryCode == "null" { - phoneNumberCountryCode = "" - } - if phoneNumber == "null" { - phoneNumber = "" - } - if country == "null" { - country = "" - } + fields := parseContactFormValues(form.Value) // Validate email format if provided. - if email != "" && !stringutil.ValidEmail(email) { + if fields.Email != "" && !stringutil.ValidEmail(fields.Email) { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("validation.invalidEmail"), nil, envelope.InputError) } contact := models.User{ - FirstName: firstName, - LastName: lastName, - Email: null.NewString(email, email != ""), - PhoneNumber: null.NewString(phoneNumber, phoneNumber != ""), - PhoneNumberCountryCode: null.NewString(phoneNumberCountryCode, phoneNumberCountryCode != ""), - Country: null.NewString(country, country != ""), + FirstName: fields.FirstName, + LastName: fields.LastName, + Email: null.NewString(fields.Email, fields.Email != ""), + PhoneNumber: null.NewString(fields.PhoneNumber, fields.PhoneNumber != ""), + PhoneNumberCountryCode: null.NewString(fields.PhoneNumberCountryCode, fields.PhoneNumberCountryCode != ""), + Country: null.NewString(fields.Country, fields.Country != ""), } if err := app.user.CreateManualContact(&contact); err != nil { @@ -169,69 +171,27 @@ func handleUpdateContact(r *fastglue.Request) error { return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("errors.parsingRequest"), nil, envelope.GeneralError) } - // Parse form data - firstName := "" - if v, ok := form.Value["first_name"]; ok && len(v) > 0 { - firstName = string(v[0]) - } - lastName := "" - if v, ok := form.Value["last_name"]; ok && len(v) > 0 { - lastName = string(v[0]) - } - email := "" - if v, ok := form.Value["email"]; ok && len(v) > 0 { - email = strings.TrimSpace(string(v[0])) - } - phoneNumber := "" - if v, ok := form.Value["phone_number"]; ok && len(v) > 0 { - phoneNumber = string(v[0]) - } - phoneNumberCountryCode := "" - if v, ok := form.Value["phone_number_country_code"]; ok && len(v) > 0 { - phoneNumberCountryCode = string(v[0]) - } - country := "" - if v, ok := form.Value["country"]; ok && len(v) > 0 { - country = string(v[0]) - } - avatarURL := "" - if v, ok := form.Value["avatar_url"]; ok && len(v) > 0 { - avatarURL = string(v[0]) - } - - // Set nulls to empty strings. - if avatarURL == "null" { - avatarURL = "" - } - if phoneNumberCountryCode == "null" { - phoneNumberCountryCode = "" - } - if phoneNumber == "null" { - phoneNumber = "" - } - if country == "null" { - country = "" - } + fields := parseContactFormValues(form.Value) // Validate mandatory fields. - if email == "" { + if fields.Email == "" { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "email"), nil, envelope.InputError) } - if !stringutil.ValidEmail(email) { + if !stringutil.ValidEmail(fields.Email) { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("validation.invalidEmail"), nil, envelope.InputError) } - if firstName == "" { + if fields.FirstName == "" { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "first_name"), nil, envelope.InputError) } contactToUpdate := models.User{ - FirstName: firstName, - LastName: lastName, - Email: null.StringFrom(email), - AvatarURL: null.NewString(avatarURL, avatarURL != ""), - PhoneNumber: null.NewString(phoneNumber, phoneNumber != ""), - PhoneNumberCountryCode: null.NewString(phoneNumberCountryCode, phoneNumberCountryCode != ""), - Country: null.NewString(country, country != ""), + FirstName: fields.FirstName, + LastName: fields.LastName, + Email: null.StringFrom(fields.Email), + AvatarURL: null.NewString(fields.AvatarURL, fields.AvatarURL != ""), + PhoneNumber: null.NewString(fields.PhoneNumber, fields.PhoneNumber != ""), + PhoneNumberCountryCode: null.NewString(fields.PhoneNumberCountryCode, fields.PhoneNumberCountryCode != ""), + Country: null.NewString(fields.Country, fields.Country != ""), } if err := app.user.UpdateContact(id, contactToUpdate); err != nil { @@ -239,7 +199,7 @@ func handleUpdateContact(r *fastglue.Request) error { } // Delete avatar? - if avatarURL == "" && contact.AvatarURL.Valid { + if fields.AvatarURL == "" && contact.AvatarURL.Valid { fileName := filepath.Base(contact.AvatarURL.String) app.media.Delete(fileName) contact.AvatarURL.Valid = false From 864ab1ccd5e1eb44bf785a4dac220b173cec9408 Mon Sep 17 00:00:00 2001 From: Gumbraise <34947108+Gumbraise@users.noreply.github.com> Date: Thu, 14 May 2026 01:53:44 +0200 Subject: [PATCH 016/178] refactor(contact): simplify form schema --- frontend/apps/main/src/features/contact/formSchema.js | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/frontend/apps/main/src/features/contact/formSchema.js b/frontend/apps/main/src/features/contact/formSchema.js index 8765806d..1922978a 100644 --- a/frontend/apps/main/src/features/contact/formSchema.js +++ b/frontend/apps/main/src/features/contact/formSchema.js @@ -47,15 +47,13 @@ export const createContactFormSchema = (t) => z.object({ .max(50, { message: t('validation.minmax', { min: 0, max: 50 }) }) - .optional() - .or(z.literal('')), + .optional(), last_name: z .string() .max(50, { message: t('validation.minmax', { min: 0, max: 50 }) }) - .optional() - .or(z.literal('')), + .optional(), email: z .string() .email({ message: t('validation.invalidEmail') }) @@ -66,8 +64,7 @@ export const createContactFormSchema = (t) => z.object({ .refine(val => !val || /^\d{1,15}$/.test(val), { message: t('validation.minmax', { min: 1, max: 15 }) }) - .optional() - .or(z.literal('')), + .optional(), phone_number_country_code: z.string().optional().nullable(), country: z.string().optional().nullable(), }) From 14d151afaf3d87aa76efa4d33ea079fc9f9b989f Mon Sep 17 00:00:00 2001 From: Gumbraise <34947108+Gumbraise@users.noreply.github.com> Date: Thu, 21 May 2026 23:43:26 +0200 Subject: [PATCH 017/178] fix(contact): treat avatar upload as best-effort after contact creation --- cmd/contacts.go | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/cmd/contacts.go b/cmd/contacts.go index 9dbef6d9..045c1b9f 100644 --- a/cmd/contacts.go +++ b/cmd/contacts.go @@ -93,15 +93,17 @@ func handleCreateContact(r *fastglue.Request) error { return sendErrorEnvelope(r, err) } - // Upload avatar if provided. + // Upload avatar if provided. Treat as best-effort: a failure does not + // roll back the already-created contact. files, ok := form.File["files"] if ok && len(files) > 0 { if err := uploadUserAvatar(r, createdContact, files); err != nil { - return sendErrorEnvelope(r, err) - } - createdContact, err = app.user.GetContactOrVisitor(contact.ID, "") - if err != nil { - return sendErrorEnvelope(r, err) + app.lo.Error("failed to upload avatar for new contact", "contact_id", contact.ID, "error", err) + } else { + createdContact, err = app.user.GetContactOrVisitor(contact.ID, "") + if err != nil { + return sendErrorEnvelope(r, err) + } } } From 45c1b6a6e993f192cd053b441c84469585f8b187 Mon Sep 17 00:00:00 2001 From: Izzaldin Date: Fri, 31 Jul 2026 00:54:38 +0300 Subject: [PATCH 018/178] fix: force light background for email messages in dark mode (#449) --- .../main/src/features/conversation/message/MessageBubble.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/apps/main/src/features/conversation/message/MessageBubble.vue b/frontend/apps/main/src/features/conversation/message/MessageBubble.vue index 4d2ececc..2bbf433d 100644 --- a/frontend/apps/main/src/features/conversation/message/MessageBubble.vue +++ b/frontend/apps/main/src/features/conversation/message/MessageBubble.vue @@ -106,7 +106,7 @@ > {{ sanitizedContent }} -
+
Date: Sat, 1 Aug 2026 21:06:22 +0530 Subject: [PATCH 019/178] render incoming email on a light canvas in dark mode --- .../src/features/conversation/message/MessageBubble.vue | 7 ++++++- frontend/shared-ui/assets/styles/main.scss | 8 ++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/frontend/apps/main/src/features/conversation/message/MessageBubble.vue b/frontend/apps/main/src/features/conversation/message/MessageBubble.vue index 2bbf433d..9b7120ef 100644 --- a/frontend/apps/main/src/features/conversation/message/MessageBubble.vue +++ b/frontend/apps/main/src/features/conversation/message/MessageBubble.vue @@ -106,7 +106,12 @@ > {{ sanitizedContent }}
-
+
Date: Sun, 2 Aug 2026 17:08:32 +0300 Subject: [PATCH 020/178] Update frontend/shared-ui/assets/styles/main.scss Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- frontend/shared-ui/assets/styles/main.scss | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/shared-ui/assets/styles/main.scss b/frontend/shared-ui/assets/styles/main.scss index 16124e93..fc84734c 100644 --- a/frontend/shared-ui/assets/styles/main.scss +++ b/frontend/shared-ui/assets/styles/main.scss @@ -259,9 +259,10 @@ // Sender colours are only legible against white, so incoming email HTML is not themed. .dark .email-light-canvas { - @apply p-2 rounded border border-border; + `@apply` p-2 rounded border border-border; + color-scheme: light; - background: #fff; + background: `#fff`; color: #1a1a1a; } From 4f061b60d9c96e783d2a4d9c5a85f519053d7457 Mon Sep 17 00:00:00 2001 From: Izzaldin Date: Sun, 2 Aug 2026 17:19:00 +0300 Subject: [PATCH 021/178] fix: remove invalid CSS syntax added by bot --- .../main/src/features/conversation/message/MessageBubble.vue | 2 +- frontend/shared-ui/assets/styles/main.scss | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/apps/main/src/features/conversation/message/MessageBubble.vue b/frontend/apps/main/src/features/conversation/message/MessageBubble.vue index 9b7120ef..3356f4e3 100644 --- a/frontend/apps/main/src/features/conversation/message/MessageBubble.vue +++ b/frontend/apps/main/src/features/conversation/message/MessageBubble.vue @@ -106,7 +106,7 @@ > {{ sanitizedContent }}
-
Date: Fri, 14 Aug 2026 13:10:38 +0000 Subject: [PATCH 022/178] feat(auth): add SSO-only login mode --- cmd/config.go | 20 +++++++++ cmd/config_test.go | 41 +++++++++++++++++++ cmd/login.go | 8 ++++ config.sample.toml | 6 +++ .../main/src/views/auth/UserLoginView.vue | 8 +++- 5 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 cmd/config_test.go diff --git a/cmd/config.go b/cmd/config.go index 8a04a219..e354ab9d 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -4,9 +4,28 @@ import ( "encoding/json" "github.com/abhinavxd/libredesk/internal/envelope" + oidcmodels "github.com/abhinavxd/libredesk/internal/oidc/models" "github.com/zerodha/fastglue" ) +const localLoginEnabledKey = "auth.local_login_enabled" + +// isLocalLoginEnabled keeps local login available by default and whenever no +// OIDC provider is enabled, preventing an administrator from locking everyone out. +func isLocalLoginEnabled(providers []oidcmodels.OIDC) bool { + if !ko.Exists(localLoginEnabledKey) || ko.Bool(localLoginEnabledKey) { + return true + } + + for _, provider := range providers { + if provider.Enabled { + return false + } + } + + return true +} + // handleGetConfig returns the public configuration needed for app initialization, this includes minimal app settings and enabled SSO providers (without secrets). func handleGetConfig(r *fastglue.Request) error { var app = r.Context.(*App) @@ -58,6 +77,7 @@ func handleGetConfig(r *fastglue.Request) error { // Add SSO providers to the response publicSettings["app.sso_providers"] = enabledProviders + publicSettings["app.local_login_enabled"] = isLocalLoginEnabled(oidcProviders) return r.SendEnvelope(publicSettings) } diff --git a/cmd/config_test.go b/cmd/config_test.go new file mode 100644 index 00000000..fcca43fa --- /dev/null +++ b/cmd/config_test.go @@ -0,0 +1,41 @@ +package main + +import ( + "testing" + + oidcmodels "github.com/abhinavxd/libredesk/internal/oidc/models" +) + +func TestIsLocalLoginEnabled(t *testing.T) { + tests := []struct { + name string + configured *bool + providers []oidcmodels.OIDC + want bool + }{ + {name: "defaults to enabled", want: true}, + {name: "remains enabled when configured", configured: boolPointer(true), want: true}, + {name: "remains enabled without an enabled OIDC provider", configured: boolPointer(false), providers: []oidcmodels.OIDC{{Enabled: false}}, want: true}, + {name: "is disabled with an enabled OIDC provider", configured: boolPointer(false), providers: []oidcmodels.OIDC{{Enabled: true}}, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ko.Delete(localLoginEnabledKey) + if tt.configured != nil { + if err := ko.Set(localLoginEnabledKey, *tt.configured); err != nil { + t.Fatalf("set config: %v", err) + } + } + t.Cleanup(func() { ko.Delete(localLoginEnabledKey) }) + + if got := isLocalLoginEnabled(tt.providers); got != tt.want { + t.Fatalf("isLocalLoginEnabled() = %v, want %v", got, tt.want) + } + }) + } +} + +func boolPointer(value bool) *bool { + return &value +} diff --git a/cmd/login.go b/cmd/login.go index 2cf3968e..c20ab830 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -21,6 +21,14 @@ func handleLogin(r *fastglue.Request) error { loginReq loginRequest ) + oidcProviders, err := app.oidc.GetAll() + if err != nil { + return sendErrorEnvelope(r, err) + } + if !isLocalLoginEnabled(oidcProviders) { + return r.SendErrorEnvelope(fasthttp.StatusForbidden, "Local login is disabled. Sign in with an SSO provider.", nil, envelope.PermissionError) + } + // Decode JSON request. if err := r.Decode(&loginReq, "json"); err != nil { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("errors.parsingRequest"), nil, envelope.InputError) diff --git a/config.sample.toml b/config.sample.toml index 713c01a8..b39aa2ff 100644 --- a/config.sample.toml +++ b/config.sample.toml @@ -34,6 +34,12 @@ read_buffer_size = 65536 # Keepalive settings. keepalive_timeout = "10s" +[auth] +# Allow users to sign in with their LibreDesk email and password. +# Disable this only after configuring and enabling at least one OIDC provider. +# LibreDesk automatically keeps local login available when no OIDC provider is enabled to prevent lockout. +local_login_enabled = true + # File upload provider to use, either `fs` or `s3`. [upload] provider = "fs" diff --git a/frontend/apps/main/src/views/auth/UserLoginView.vue b/frontend/apps/main/src/views/auth/UserLoginView.vue index 3cf65fd1..5ca74ff1 100644 --- a/frontend/apps/main/src/views/auth/UserLoginView.vue +++ b/frontend/apps/main/src/views/auth/UserLoginView.vue @@ -32,7 +32,7 @@ {{ oidcProvider.name }} -
+
@@ -42,7 +42,7 @@
-
+