aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBobby <[email protected]>2026-03-08 05:43:04 +0530
committerBobby <[email protected]>2026-03-08 05:43:04 +0530
commit662dd2069dc8590e8b54823a33726464cf10c4e7 (patch)
tree55a740e6114440d7e311afd3f5ba79a7101965f8
parentd21ea918864a8b18fef94bbfaec8097444be1b17 (diff)
downloaddove-662dd2069dc8590e8b54823a33726464cf10c4e7.tar.xz
dove-662dd2069dc8590e8b54823a33726464cf10c4e7.zip
feat(domains): enhance TLD and domain management with edit and delete functionality
- Added edit and delete buttons for TLDs in the TLD management interface. - Implemented a modal confirmation for delete actions across TLDs, mailboxes, users, and aliases. - Created separate edit pages for domains and TLDs with forms for updating their details. - Improved user experience by adding alerts for error messages and success notifications. feat(mail): streamline mailbox management with alias support - Introduced alias creation and deletion functionality for mailboxes. - Enhanced mailbox edit interface to include alias management. - Added dropdowns for selecting users and domains when creating aliases. fix(alerts): implement alert system for error messages - Developed a reusable alert component to display error messages. - Integrated alert dismiss functionality with automatic timeout for user notifications. refactor: general code improvements and organization - Updated error handling in the backend to support HTMX requests. - Refactored redirect functions to handle HTMX redirects appropriately.
-rw-r--r--controllers/domain/domain.go54
-rw-r--r--controllers/mail/mail.go118
-rw-r--r--pages/domain/domain.go32
-rw-r--r--pages/mail/mailboxes.go19
-rw-r--r--pages/mail/users.go19
-rw-r--r--repositories/domain/domain.go6
-rw-r--r--repositories/domain/tld.go9
-rw-r--r--repositories/mail/alias.go29
-rw-r--r--repositories/mail/mailbox.go45
-rw-r--r--repositories/mail/user.go14
-rw-r--r--router/domain.go7
-rw-r--r--router/mail.go10
-rw-r--r--services/domain/domain.go85
-rw-r--r--services/domain/messages.go3
-rw-r--r--services/domain/tld.go61
-rw-r--r--services/mail/aliases.go76
-rw-r--r--services/mail/mailboxes.go61
-rw-r--r--services/mail/messages.go13
-rw-r--r--services/mail/users.go56
-rw-r--r--static/css/tailwind.css44
-rw-r--r--static/js/alert.js28
-rw-r--r--static/js/confirm.js2
-rw-r--r--templates/domains/editdomain.django5
-rw-r--r--templates/domains/edittld.django5
-rw-r--r--templates/domains/htmx/domains.htmx.django29
-rw-r--r--templates/domains/htmx/editdomain.htmx.django49
-rw-r--r--templates/domains/htmx/edittld.htmx.django22
-rw-r--r--templates/domains/htmx/tlds.htmx.django8
-rw-r--r--templates/layouts/base.django9
-rw-r--r--templates/layouts/dashboard.django2
-rw-r--r--templates/mail/editmailbox.django5
-rw-r--r--templates/mail/edituser.django5
-rw-r--r--templates/mail/htmx/editmailbox.htmx.django139
-rw-r--r--templates/mail/htmx/edituser.htmx.django26
-rw-r--r--templates/mail/htmx/mailboxes.htmx.django55
-rw-r--r--templates/mail/htmx/users.htmx.django31
-rw-r--r--templates/partials/alert.django17
-rw-r--r--utils/shortcuts/error.go35
-rw-r--r--utils/shortcuts/redirect.go21
39 files changed, 1224 insertions, 30 deletions
diff --git a/controllers/domain/domain.go b/controllers/domain/domain.go
index 5613ade..ac61c94 100644
--- a/controllers/domain/domain.go
+++ b/controllers/domain/domain.go
@@ -1,6 +1,8 @@
package domain
import (
+ "strconv"
+
domainService "dove/services/domain"
"dove/utils/meta"
"dove/utils/shortcuts"
@@ -22,6 +24,39 @@ func CreateDomain(context *fiber.Ctx) error {
return shortcuts.Redirect(context, "domains.manage")
}
+func UpdateDomain(context *fiber.Ctx) error {
+ domainID, parseError := strconv.ParseUint(meta.Request(context).Param("id"), 10, 64)
+ if parseError != nil {
+ return shortcuts.BadRequestError(context, parseError)
+ }
+
+ body, bodyError := meta.Body[domainService.UpdateDomainRequest](context)
+ if bodyError != nil {
+ return shortcuts.BadRequestError(context, bodyError)
+ }
+
+ serviceError := domainService.UpdateDomain(uint(domainID), body)
+ if serviceError != nil {
+ return shortcuts.HandleError(context, serviceError)
+ }
+
+ return shortcuts.Redirect(context, "domains.manage")
+}
+
+func DeleteDomain(context *fiber.Ctx) error {
+ domainID, parseError := strconv.ParseUint(meta.Request(context).Param("id"), 10, 64)
+ if parseError != nil {
+ return shortcuts.BadRequestError(context, parseError)
+ }
+
+ serviceError := domainService.DeleteDomain(uint(domainID))
+ if serviceError != nil {
+ return shortcuts.HandleError(context, serviceError)
+ }
+
+ return shortcuts.Redirect(context, "domains.manage")
+}
+
func CreateTLD(context *fiber.Ctx) error {
body, parseError := meta.Body[domainService.CreateTLDRequest](context)
if parseError != nil {
@@ -36,6 +71,25 @@ func CreateTLD(context *fiber.Ctx) error {
return shortcuts.Redirect(context, "domains.tlds")
}
+func UpdateTLD(context *fiber.Ctx) error {
+ tldID, parseError := strconv.ParseUint(meta.Request(context).Param("id"), 10, 64)
+ if parseError != nil {
+ return shortcuts.BadRequestError(context, parseError)
+ }
+
+ body, bodyError := meta.Body[domainService.UpdateTLDRequest](context)
+ if bodyError != nil {
+ return shortcuts.BadRequestError(context, bodyError)
+ }
+
+ serviceError := domainService.UpdateTLD(uint(tldID), body)
+ if serviceError != nil {
+ return shortcuts.HandleError(context, serviceError)
+ }
+
+ return shortcuts.Redirect(context, "domains.tlds")
+}
+
func DeleteTLD(context *fiber.Ctx) error {
serviceError := domainService.DeleteTLD(meta.Request(context).Param("name"))
if serviceError != nil {
diff --git a/controllers/mail/mail.go b/controllers/mail/mail.go
index 544a51b..58df08d 100644
--- a/controllers/mail/mail.go
+++ b/controllers/mail/mail.go
@@ -1,9 +1,14 @@
package mail
import (
+ "fmt"
+ "strconv"
+ "strings"
+
mailService "dove/services/mail"
"dove/utils/meta"
"dove/utils/shortcuts"
+ "dove/utils/urls"
"github.com/gofiber/fiber/v2"
)
@@ -22,6 +27,39 @@ func CreateUser(context *fiber.Ctx) error {
return shortcuts.Redirect(context, "mail.users")
}
+func UpdateUser(context *fiber.Ctx) error {
+ userID, parseError := strconv.ParseUint(meta.Request(context).Param("id"), 10, 64)
+ if parseError != nil {
+ return shortcuts.BadRequestError(context, parseError)
+ }
+
+ body, bodyError := meta.Body[mailService.UpdateUserRequest](context)
+ if bodyError != nil {
+ return shortcuts.BadRequestError(context, bodyError)
+ }
+
+ serviceError := mailService.UpdateUser(uint(userID), body)
+ if serviceError != nil {
+ return shortcuts.HandleError(context, serviceError)
+ }
+
+ return shortcuts.Redirect(context, "mail.users")
+}
+
+func DeleteUser(context *fiber.Ctx) error {
+ userID, parseError := strconv.ParseUint(meta.Request(context).Param("id"), 10, 64)
+ if parseError != nil {
+ return shortcuts.BadRequestError(context, parseError)
+ }
+
+ serviceError := mailService.DeleteUser(uint(userID))
+ if serviceError != nil {
+ return shortcuts.HandleError(context, serviceError)
+ }
+
+ return shortcuts.Redirect(context, "mail.users")
+}
+
func CreateMailbox(context *fiber.Ctx) error {
body, parseError := meta.Body[mailService.CreateMailboxRequest](context)
if parseError != nil {
@@ -34,4 +72,82 @@ func CreateMailbox(context *fiber.Ctx) error {
}
return shortcuts.Redirect(context, "mail.mailboxes")
-} \ No newline at end of file
+}
+
+func UpdateMailbox(context *fiber.Ctx) error {
+ mailboxID, parseError := strconv.ParseUint(meta.Request(context).Param("id"), 10, 64)
+ if parseError != nil {
+ return shortcuts.BadRequestError(context, parseError)
+ }
+
+ body, bodyError := meta.Body[mailService.UpdateMailboxRequest](context)
+ if bodyError != nil {
+ return shortcuts.BadRequestError(context, bodyError)
+ }
+
+ serviceError := mailService.UpdateMailbox(uint(mailboxID), body)
+ if serviceError != nil {
+ return shortcuts.HandleError(context, serviceError)
+ }
+
+ return shortcuts.Redirect(context, "mail.mailboxes")
+}
+
+func DeleteMailbox(context *fiber.Ctx) error {
+ mailboxID, parseError := strconv.ParseUint(meta.Request(context).Param("id"), 10, 64)
+ if parseError != nil {
+ return shortcuts.BadRequestError(context, parseError)
+ }
+
+ serviceError := mailService.DeleteMailbox(uint(mailboxID))
+ if serviceError != nil {
+ return shortcuts.HandleError(context, serviceError)
+ }
+
+ return shortcuts.Redirect(context, "mail.mailboxes")
+}
+
+func mailboxEditPath(mailboxID uint) string {
+ editPath, _ := urls.GetFullPath("mail.mailboxes.edit")
+ return strings.Replace(editPath, ":id", fmt.Sprintf("%d", mailboxID), 1)
+}
+
+func CreateAlias(context *fiber.Ctx) error {
+ mailboxID, parseError := strconv.ParseUint(meta.Request(context).Param("id"), 10, 64)
+ if parseError != nil {
+ return shortcuts.BadRequestError(context, parseError)
+ }
+
+ body, bodyError := meta.Body[mailService.CreateAliasRequest](context)
+ if bodyError != nil {
+ return shortcuts.BadRequestError(context, bodyError)
+ }
+
+ body.MailboxID = uint(mailboxID)
+
+ serviceError := mailService.CreateAlias(body)
+ if serviceError != nil {
+ return shortcuts.HandleError(context, serviceError)
+ }
+
+ return shortcuts.RedirectToPath(context, mailboxEditPath(uint(mailboxID)))
+}
+
+func DeleteAlias(context *fiber.Ctx) error {
+ aliasID, parseError := strconv.ParseUint(meta.Request(context).Param("alias_id"), 10, 64)
+ if parseError != nil {
+ return shortcuts.BadRequestError(context, parseError)
+ }
+
+ mailboxID, mailboxParseError := strconv.ParseUint(meta.Request(context).Param("id"), 10, 64)
+ if mailboxParseError != nil {
+ return shortcuts.BadRequestError(context, mailboxParseError)
+ }
+
+ serviceError := mailService.DeleteAlias(uint(aliasID))
+ if serviceError != nil {
+ return shortcuts.HandleError(context, serviceError)
+ }
+
+ return shortcuts.RedirectToPath(context, mailboxEditPath(uint(mailboxID)))
+}
diff --git a/pages/domain/domain.go b/pages/domain/domain.go
index cb1022b..7c99f21 100644
--- a/pages/domain/domain.go
+++ b/pages/domain/domain.go
@@ -1,6 +1,8 @@
package domain
import (
+ "strconv"
+
domainService "dove/services/domain"
"dove/utils/meta"
"dove/utils/shortcuts"
@@ -28,7 +30,37 @@ func NewDomain(context *fiber.Ctx) error {
return shortcuts.Render(context, "domains/newdomain", domainService.DomainFormData())
}
+func EditDomain(context *fiber.Ctx) error {
+ domainID, parseError := strconv.ParseUint(meta.Request(context).Param("id"), 10, 64)
+ if parseError != nil {
+ return shortcuts.BadRequestError(context, parseError)
+ }
+
+ formData, serviceError := domainService.EditDomainFormData(uint(domainID))
+ if serviceError != nil {
+ return shortcuts.HandleError(context, serviceError)
+ }
+
+ meta.SetPageTitle(context, "Edit Domain")
+ return shortcuts.Render(context, "domains/editdomain", formData)
+}
+
func NewTLD(context *fiber.Ctx) error {
meta.SetPageTitle(context, "New TLD")
return shortcuts.Render(context, "domains/newtld", nil)
}
+
+func EditTLD(context *fiber.Ctx) error {
+ tldID, parseError := strconv.ParseUint(meta.Request(context).Param("id"), 10, 64)
+ if parseError != nil {
+ return shortcuts.BadRequestError(context, parseError)
+ }
+
+ formData, serviceError := domainService.EditTLDFormData(uint(tldID))
+ if serviceError != nil {
+ return shortcuts.HandleError(context, serviceError)
+ }
+
+ meta.SetPageTitle(context, "Edit TLD")
+ return shortcuts.Render(context, "domains/edittld", formData)
+} \ No newline at end of file
diff --git a/pages/mail/mailboxes.go b/pages/mail/mailboxes.go
index 36d9041..f679839 100644
--- a/pages/mail/mailboxes.go
+++ b/pages/mail/mailboxes.go
@@ -1,6 +1,8 @@
package mail
import (
+ "strconv"
+
mailService "dove/services/mail"
"dove/utils/meta"
"dove/utils/shortcuts"
@@ -13,6 +15,21 @@ func NewMailbox(context *fiber.Ctx) error {
return shortcuts.Render(context, "mail/newmailbox", mailService.MailboxFormData())
}
+func EditMailbox(context *fiber.Ctx) error {
+ mailboxID, parseError := strconv.ParseUint(meta.Request(context).Param("id"), 10, 64)
+ if parseError != nil {
+ return shortcuts.BadRequestError(context, parseError)
+ }
+
+ formData, serviceError := mailService.EditMailboxFormData(uint(mailboxID))
+ if serviceError != nil {
+ return shortcuts.HandleError(context, serviceError)
+ }
+
+ meta.SetPageTitle(context, "Edit Mailbox")
+ return shortcuts.Render(context, "mail/editmailbox", formData)
+}
+
func Mailboxes(context *fiber.Ctx) error {
meta.SetPageTitle(context, "Mailboxes")
@@ -21,4 +38,4 @@ func Mailboxes(context *fiber.Ctx) error {
search := context.Query("search")
return shortcuts.Render(context, "mail/mailboxes", mailService.ListMailboxes(pagination, sorting, search))
-}
+} \ No newline at end of file
diff --git a/pages/mail/users.go b/pages/mail/users.go
index 8700c80..6fe057d 100644
--- a/pages/mail/users.go
+++ b/pages/mail/users.go
@@ -1,6 +1,8 @@
package mail
import (
+ "strconv"
+
mailService "dove/services/mail"
"dove/utils/meta"
"dove/utils/shortcuts"
@@ -13,6 +15,21 @@ func NewUser(context *fiber.Ctx) error {
return shortcuts.Render(context, "mail/newuser", nil)
}
+func EditUser(context *fiber.Ctx) error {
+ userID, parseError := strconv.ParseUint(meta.Request(context).Param("id"), 10, 64)
+ if parseError != nil {
+ return shortcuts.BadRequestError(context, parseError)
+ }
+
+ formData, serviceError := mailService.EditUserFormData(uint(userID))
+ if serviceError != nil {
+ return shortcuts.HandleError(context, serviceError)
+ }
+
+ meta.SetPageTitle(context, "Edit User")
+ return shortcuts.Render(context, "mail/edituser", formData)
+}
+
func Users(context *fiber.Ctx) error {
meta.SetPageTitle(context, "Users")
@@ -21,4 +38,4 @@ func Users(context *fiber.Ctx) error {
search := context.Query("search")
return shortcuts.Render(context, "mail/users", mailService.ListUsers(pagination, sorting, search))
-}
+} \ No newline at end of file
diff --git a/repositories/domain/domain.go b/repositories/domain/domain.go
index 5fe7778..d4d1363 100644
--- a/repositories/domain/domain.go
+++ b/repositories/domain/domain.go
@@ -46,6 +46,12 @@ func DeleteDomain(targetDomain *domain.Domain) error {
return database.DB.Delete(targetDomain).Error
}
+func FindDomainsByTLDID(tldID uint) []domain.Domain {
+ var domains []domain.Domain
+ database.DB.Preload("TLD").Where("tld_id = ?", tldID).Find(&domains)
+ return domains
+}
+
func CountDomainsByTLDID(tldID uint) int64 {
var count int64
database.DB.Model(&domain.Domain{}).Where("tld_id = ?", tldID).Count(&count)
diff --git a/repositories/domain/tld.go b/repositories/domain/tld.go
index c48e213..9528686 100644
--- a/repositories/domain/tld.go
+++ b/repositories/domain/tld.go
@@ -13,6 +13,15 @@ func AllTLDs() []domain.TLD {
return tlds
}
+func FindTLDByID(tldID uint) *domain.TLD {
+ var tld domain.TLD
+ result := database.DB.First(&tld, tldID)
+ if result.Error == gorm.ErrRecordNotFound {
+ return nil
+ }
+ return &tld
+}
+
func FindTLDByName(name string) *domain.TLD {
var tld domain.TLD
result := database.DB.Where("name = ?", name).First(&tld)
diff --git a/repositories/mail/alias.go b/repositories/mail/alias.go
index 49a5396..67ecc38 100644
--- a/repositories/mail/alias.go
+++ b/repositories/mail/alias.go
@@ -3,16 +3,37 @@ package mail
import (
"dove/database"
"dove/models/mail"
-
- "gorm.io/gorm"
)
func FindAliasByAddress(address string) *mail.Alias {
var alias mail.Alias
- result := database.DB.Preload("Mailbox").Where("source_address = ?", address).First(&alias)
- if result.Error == gorm.ErrRecordNotFound {
+ result := database.DB.Where("source_address = ?", address).First(&alias)
+ if result.Error != nil {
return nil
}
return &alias
}
+
+func FindAliasByID(aliasID uint) *mail.Alias {
+ var alias mail.Alias
+ result := database.DB.First(&alias, aliasID)
+ if result.Error != nil {
+ return nil
+ }
+ return &alias
+}
+
+func FindAliasesByMailboxID(mailboxID uint) []mail.Alias {
+ var aliases []mail.Alias
+ database.DB.Where("mailbox_id = ?", mailboxID).Order("source_address ASC").Find(&aliases)
+ return aliases
+}
+
+func CreateAlias(alias *mail.Alias) error {
+ return database.DB.Create(alias).Error
+}
+
+func DeleteAlias(alias *mail.Alias) error {
+ return database.DB.Delete(alias).Error
+}
diff --git a/repositories/mail/mailbox.go b/repositories/mail/mailbox.go
index 1bfb607..dfd1fed 100644
--- a/repositories/mail/mailbox.go
+++ b/repositories/mail/mailbox.go
@@ -1,6 +1,8 @@
package mail
import (
+ "strings"
+
"dove/database"
"dove/models/mail"
"dove/utils/meta"
@@ -39,6 +41,49 @@ func ListMailboxes(pagination meta.Pagination, sorting meta.Sorting, search stri
return mailboxes, total
}
+func FindMailboxByID(mailboxID uint) *mail.Mailbox {
+ var mailbox mail.Mailbox
+ result := database.DB.First(&mailbox, mailboxID)
+ if result.Error == gorm.ErrRecordNotFound {
+ return nil
+ }
+ return &mailbox
+}
+
+func UpdateMailbox(mailbox *mail.Mailbox) error {
+ return database.DB.Save(mailbox).Error
+}
+
+func DeleteMailbox(mailbox *mail.Mailbox) error {
+ return database.DB.Delete(mailbox).Error
+}
+
+func FindMailboxByIDWithRelations(mailboxID uint) *mail.Mailbox {
+ var mailbox mail.Mailbox
+ result := database.DB.Preload("User").Preload("Domain").Preload("Domain.TLD").First(&mailbox, mailboxID)
+ if result.Error == gorm.ErrRecordNotFound {
+ return nil
+ }
+ return &mailbox
+}
+
+func RebuildMailboxAddressesByDomainID(domainID uint) error {
+ var mailboxes []mail.Mailbox
+ database.DB.Where("domain_id = ?", domainID).Preload("Domain").Preload("Domain.TLD").Find(&mailboxes)
+ for index := range mailboxes {
+ localPart := strings.Split(mailboxes[index].Address, "@")[0]
+ mailboxes[index].Address = localPart + "@" + mailboxes[index].Domain.Name + "." + mailboxes[index].Domain.TLD.Name
+ database.DB.Save(&mailboxes[index])
+ }
+ return nil
+}
+
+func CountMailboxesByDomainID(domainID uint) int64 {
+ var count int64
+ database.DB.Model(&mail.Mailbox{}).Where("domain_id = ?", domainID).Count(&count)
+ return count
+}
+
func CountMailboxes() int64 {
var count int64
database.DB.Model(&mail.Mailbox{}).Count(&count)
diff --git a/repositories/mail/user.go b/repositories/mail/user.go
index a045dc5..0a3da40 100644
--- a/repositories/mail/user.go
+++ b/repositories/mail/user.go
@@ -55,6 +55,20 @@ func AllUsers() []mail.User {
return users
}
+func UpdateUser(user *mail.User) error {
+ return database.DB.Save(user).Error
+}
+
+func DeleteUser(user *mail.User) error {
+ return database.DB.Delete(user).Error
+}
+
+func CountMailboxesByUserID(userID uint) int64 {
+ var count int64
+ database.DB.Model(&mail.Mailbox{}).Where("user_id = ?", userID).Count(&count)
+ return count
+}
+
func CountUsers() int64 {
var count int64
database.DB.Model(&mail.User{}).Count(&count)
diff --git a/router/domain.go b/router/domain.go
index 50b1163..abae116 100644
--- a/router/domain.go
+++ b/router/domain.go
@@ -14,8 +14,13 @@ func init() {
urls.Path(urls.Get, "/tlds", auth.RequireAuthentication(domainPage.TLDs), "tlds")
urls.Path(urls.Get, "/tlds/new", auth.RequireAuthentication(domainPage.NewTLD), "tlds.new")
urls.Path(urls.Post, "/tlds", auth.RequireAuthentication(domainController.CreateTLD), "tlds.create")
+ urls.Path(urls.Get, "/tlds/:id/edit", auth.RequireAuthentication(domainPage.EditTLD), "tlds.edit")
+ urls.Path(urls.Put, "/tlds/:id", auth.RequireAuthentication(domainController.UpdateTLD), "tlds.update")
urls.Path(urls.Delete, "/tlds/:name", auth.RequireAuthentication(domainController.DeleteTLD), "tlds.delete")
urls.Path(urls.Get, "/manage", auth.RequireAuthentication(domainPage.Domains), "manage")
urls.Path(urls.Get, "/manage/new", auth.RequireAuthentication(domainPage.NewDomain), "manage.new")
urls.Path(urls.Post, "/manage", auth.RequireAuthentication(domainController.CreateDomain), "manage.create")
-}
+ urls.Path(urls.Get, "/manage/:id/edit", auth.RequireAuthentication(domainPage.EditDomain), "manage.edit")
+ urls.Path(urls.Put, "/manage/:id", auth.RequireAuthentication(domainController.UpdateDomain), "manage.update")
+ urls.Path(urls.Delete, "/manage/:id", auth.RequireAuthentication(domainController.DeleteDomain), "manage.delete")
+} \ No newline at end of file
diff --git a/router/mail.go b/router/mail.go
index e94f470..3833052 100644
--- a/router/mail.go
+++ b/router/mail.go
@@ -14,8 +14,16 @@ func init() {
urls.Path(urls.Get, "/mailboxes", auth.RequireAuthentication(mailPage.Mailboxes), "mailboxes")
urls.Path(urls.Get, "/mailboxes/new", auth.RequireAuthentication(mailPage.NewMailbox), "mailboxes.new")
urls.Path(urls.Post, "/mailboxes", auth.RequireAuthentication(mailController.CreateMailbox), "mailboxes.create")
+ urls.Path(urls.Get, "/mailboxes/:id/edit", auth.RequireAuthentication(mailPage.EditMailbox), "mailboxes.edit")
+ urls.Path(urls.Put, "/mailboxes/:id", auth.RequireAuthentication(mailController.UpdateMailbox), "mailboxes.update")
urls.Path(urls.Get, "/mailboxes/:address", auth.RequireAuthentication(mailPage.Mailbox), "mailbox")
urls.Path(urls.Get, "/users", auth.RequireAuthentication(mailPage.Users), "users")
urls.Path(urls.Get, "/users/new", auth.RequireAuthentication(mailPage.NewUser), "users.new")
urls.Path(urls.Post, "/users", auth.RequireAuthentication(mailController.CreateUser), "users.create")
-}
+ urls.Path(urls.Get, "/users/:id/edit", auth.RequireAuthentication(mailPage.EditUser), "users.edit")
+ urls.Path(urls.Put, "/users/:id", auth.RequireAuthentication(mailController.UpdateUser), "users.update")
+ urls.Path(urls.Delete, "/users/:id", auth.RequireAuthentication(mailController.DeleteUser), "users.delete")
+ urls.Path(urls.Delete, "/mailboxes/:id", auth.RequireAuthentication(mailController.DeleteMailbox), "mailboxes.delete")
+ urls.Path(urls.Post, "/mailboxes/:id/aliases", auth.RequireAuthentication(mailController.CreateAlias), "aliases.create")
+ urls.Path(urls.Delete, "/mailboxes/:id/aliases/:alias_id", auth.RequireAuthentication(mailController.DeleteAlias), "aliases.delete")
+} \ No newline at end of file
diff --git a/services/domain/domain.go b/services/domain/domain.go
index 957bd36..f320189 100644
--- a/services/domain/domain.go
+++ b/services/domain/domain.go
@@ -5,6 +5,7 @@ import (
domainModel "dove/models/domain"
domainRepo "dove/repositories/domain"
+ mailRepo "dove/repositories/mail"
"dove/utils/shortcuts"
"dove/utils/validate"
)
@@ -14,6 +15,16 @@ type CreateDomainRequest struct {
TLDName string `form:"tld_name"`
}
+type UpdateDomainRequest struct {
+ Name string `form:"name"`
+ TLDName string `form:"tld_name"`
+}
+
+type EditDomainFormResponse struct {
+ Domain domainModel.Domain `json:"domain"`
+ TLDs []domainModel.TLD `json:"tlds"`
+}
+
type DomainListResponse struct {
Domains []domainModel.Domain `json:"domains"`
}
@@ -77,3 +88,77 @@ func CreateDomain(request CreateDomainRequest) *shortcuts.Error {
return nil
}
+
+func EditDomainFormData(domainID uint) (*EditDomainFormResponse, *shortcuts.Error) {
+ foundDomain := domainRepo.FindDomainByID(domainID)
+ if foundDomain == nil {
+ return nil, shortcuts.ServiceError(shortcuts.NotFound, DomainNotFound)
+ }
+
+ return &EditDomainFormResponse{
+ Domain: *foundDomain,
+ TLDs: domainRepo.AllTLDs(),
+ }, nil
+}
+
+func UpdateDomain(domainID uint, request UpdateDomainRequest) *shortcuts.Error {
+ foundDomain := domainRepo.FindDomainByID(domainID)
+ if foundDomain == nil {
+ return shortcuts.ServiceError(shortcuts.NotFound, DomainNotFound)
+ }
+
+ name := strings.TrimSpace(strings.ToLower(request.Name))
+ tldName := strings.TrimSpace(strings.ToLower(request.TLDName))
+
+ switch {
+ case name == "":
+ return shortcuts.ServiceError(shortcuts.BadRequest, DomainNameRequired)
+ case !validate.DNSLabel(name):
+ return shortcuts.ServiceError(shortcuts.BadRequest, DomainNameInvalid)
+ case tldName == "":
+ return shortcuts.ServiceError(shortcuts.BadRequest, DomainTLDRequired)
+ }
+
+ tld := domainRepo.FindTLDByName(tldName)
+ if tld == nil {
+ return shortcuts.ServiceError(shortcuts.Unprocessable, TLDNotFound)
+ }
+
+ nameChanged := name != foundDomain.Name || tld.ID != foundDomain.TLDID
+
+ if nameChanged {
+ if domainRepo.FindDomainByFullName(name, tldName) != nil {
+ return shortcuts.ServiceError(shortcuts.Unprocessable, DomainAlreadyExists)
+ }
+ }
+
+ foundDomain.Name = name
+ foundDomain.TLDID = tld.ID
+
+ if updateError := domainRepo.UpdateDomain(foundDomain); updateError != nil {
+ return shortcuts.ServiceError(shortcuts.Internal, DomainUpdateFailed)
+ }
+
+ if nameChanged {
+ mailRepo.RebuildMailboxAddressesByDomainID(foundDomain.ID)
+ }
+
+ return nil
+}
+
+func DeleteDomain(domainID uint) *shortcuts.Error {
+ foundDomain := domainRepo.FindDomainByID(domainID)
+ if foundDomain == nil {
+ return shortcuts.ServiceError(shortcuts.NotFound, DomainNotFound)
+ }
+
+ if mailRepo.CountMailboxesByDomainID(foundDomain.ID) > 0 {
+ return shortcuts.ServiceError(shortcuts.Unprocessable, DomainHasMailboxes)
+ }
+
+ if deleteError := domainRepo.DeleteDomain(foundDomain); deleteError != nil {
+ return shortcuts.ServiceError(shortcuts.Internal, DomainDeletionFailed)
+ }
+
+ return nil
+}
diff --git a/services/domain/messages.go b/services/domain/messages.go
index 70be560..583fab0 100644
--- a/services/domain/messages.go
+++ b/services/domain/messages.go
@@ -3,9 +3,12 @@ package domain
const (
DomainAlreadyExists = "A domain with this name already exists under this TLD."
DomainCreationFailed = "Failed to create domain."
+ DomainDeletionFailed = "Failed to delete domain."
+ DomainHasMailboxes = "Cannot delete a domain that has mailboxes. Remove all mailboxes first."
DomainNameInvalid = "Domain name must contain only lowercase letters, numbers, and hyphens."
DomainNameRequired = "Domain name is required."
DomainNotFound = "Domain not found."
+ DomainUpdateFailed = "Failed to update domain."
DomainTLDRequired = "A TLD must be selected for the domain."
TLDAlreadyExists = "A TLD with this name already exists."
TLDCreationFailed = "Failed to create TLD."
diff --git a/services/domain/tld.go b/services/domain/tld.go
index d9140b7..2cb7c05 100644
--- a/services/domain/tld.go
+++ b/services/domain/tld.go
@@ -5,6 +5,7 @@ import (
domainModel "dove/models/domain"
domainRepo "dove/repositories/domain"
+ mailRepo "dove/repositories/mail"
"dove/utils/shortcuts"
"dove/utils/validate"
)
@@ -13,6 +14,14 @@ type CreateTLDRequest struct {
Name string `form:"name"`
}
+type UpdateTLDRequest struct {
+ Name string `form:"name"`
+}
+
+type EditTLDFormResponse struct {
+ TLD domainModel.TLD `json:"tld"`
+}
+
func AllTLDs() []domainModel.TLD {
return domainRepo.AllTLDs()
}
@@ -41,6 +50,58 @@ func CreateTLD(request CreateTLDRequest) *shortcuts.Error {
return nil
}
+func EditTLDFormData(tldID uint) (*EditTLDFormResponse, *shortcuts.Error) {
+ tld := domainRepo.FindTLDByID(tldID)
+ if tld == nil {
+ return nil, shortcuts.ServiceError(shortcuts.NotFound, TLDNotFound)
+ }
+
+ if tld.IsDefault {
+ return nil, shortcuts.ServiceError(shortcuts.Forbidden, TLDProtected)
+ }
+
+ return &EditTLDFormResponse{TLD: *tld}, nil
+}
+
+func UpdateTLD(tldID uint, request UpdateTLDRequest) *shortcuts.Error {
+ tld := domainRepo.FindTLDByID(tldID)
+ if tld == nil {
+ return shortcuts.ServiceError(shortcuts.NotFound, TLDNotFound)
+ }
+
+ if tld.IsDefault {
+ return shortcuts.ServiceError(shortcuts.Forbidden, TLDProtected)
+ }
+
+ newName := strings.TrimSpace(strings.ToLower(request.Name))
+
+ switch {
+ case newName == "":
+ return shortcuts.ServiceError(shortcuts.BadRequest, TLDNameRequired)
+ case !validate.DNSLabel(newName):
+ return shortcuts.ServiceError(shortcuts.BadRequest, TLDNameInvalid)
+ }
+
+ if newName != tld.Name {
+ if domainRepo.FindTLDByName(newName) != nil {
+ return shortcuts.ServiceError(shortcuts.Unprocessable, TLDAlreadyExists)
+ }
+ }
+
+ tld.Name = newName
+
+ if updateError := domainRepo.UpdateTLD(tld); updateError != nil {
+ return shortcuts.ServiceError(shortcuts.Internal, TLDUpdateFailed)
+ }
+
+ domains := domainRepo.FindDomainsByTLDID(tld.ID)
+ for _, domain := range domains {
+ mailRepo.RebuildMailboxAddressesByDomainID(domain.ID)
+ }
+
+ return nil
+}
+
func DeleteTLD(name string) *shortcuts.Error {
tld := domainRepo.FindTLDByName(name)
diff --git a/services/mail/aliases.go b/services/mail/aliases.go
new file mode 100644
index 0000000..2999681
--- /dev/null
+++ b/services/mail/aliases.go
@@ -0,0 +1,76 @@
+package mail
+
+import (
+ "strings"
+
+ domainRepo "dove/repositories/domain"
+ mailModel "dove/models/mail"
+ mailRepo "dove/repositories/mail"
+ "dove/utils/shortcuts"
+ "dove/utils/validate"
+)
+
+type CreateAliasRequest struct {
+ LocalPart string `form:"local_part"`
+ DomainID uint `form:"domain_id"`
+ MailboxID uint
+}
+
+func CreateAlias(request CreateAliasRequest) *shortcuts.Error {
+ localPart := strings.TrimSpace(request.LocalPart)
+
+ switch {
+ case localPart == "":
+ return shortcuts.ServiceError(shortcuts.BadRequest, LocalPartRequired)
+ case !validate.EmailLocalPart(localPart):
+ return shortcuts.ServiceError(shortcuts.BadRequest, LocalPartInvalid)
+ case request.DomainID == 0:
+ return shortcuts.ServiceError(shortcuts.BadRequest, DomainRequired)
+ case request.MailboxID == 0:
+ return shortcuts.ServiceError(shortcuts.BadRequest, MailboxNotFound)
+ }
+
+ mailbox := mailRepo.FindMailboxByID(request.MailboxID)
+ if mailbox == nil {
+ return shortcuts.ServiceError(shortcuts.NotFound, MailboxNotFound)
+ }
+
+ foundDomain := domainRepo.FindDomainByID(request.DomainID)
+ if foundDomain == nil {
+ return shortcuts.ServiceError(shortcuts.Unprocessable, DomainNotFound)
+ }
+
+ sourceAddress := localPart + "@" + foundDomain.Name + "." + foundDomain.TLD.Name
+
+ if mailRepo.FindMailboxByAddress(sourceAddress) != nil {
+ return shortcuts.ServiceError(shortcuts.Unprocessable, AliasAlreadyExists)
+ }
+
+ if mailRepo.FindAliasByAddress(sourceAddress) != nil {
+ return shortcuts.ServiceError(shortcuts.Unprocessable, AliasAlreadyExists)
+ }
+
+ alias := &mailModel.Alias{
+ SourceAddress: sourceAddress,
+ MailboxID: request.MailboxID,
+ }
+
+ if createError := mailRepo.CreateAlias(alias); createError != nil {
+ return shortcuts.ServiceError(shortcuts.Internal, AliasCreationFailed)
+ }
+
+ return nil
+}
+
+func DeleteAlias(aliasID uint) *shortcuts.Error {
+ alias := mailRepo.FindAliasByID(aliasID)
+ if alias == nil {
+ return shortcuts.ServiceError(shortcuts.NotFound, AliasNotFound)
+ }
+
+ if deleteError := mailRepo.DeleteAlias(alias); deleteError != nil {
+ return shortcuts.ServiceError(shortcuts.Internal, AliasDeletionFailed)
+ }
+
+ return nil
+}
diff --git a/services/mail/mailboxes.go b/services/mail/mailboxes.go
index 041c64b..811e6bc 100644
--- a/services/mail/mailboxes.go
+++ b/services/mail/mailboxes.go
@@ -18,11 +18,22 @@ type CreateMailboxRequest struct {
UserID uint `form:"user_id"`
}
+type UpdateMailboxRequest struct {
+ UserID uint `form:"user_id"`
+}
+
type MailboxFormResponse struct {
Users []mailModel.User `json:"users"`
Domains []domainModel.Domain `json:"domains"`
}
+type EditMailboxFormResponse struct {
+ Mailbox mailModel.Mailbox `json:"mailbox"`
+ Users []mailModel.User `json:"users"`
+ Aliases []mailModel.Alias `json:"aliases"`
+ Domains []domainModel.Domain `json:"domains"`
+}
+
type MailboxView struct {
Address string
}
@@ -80,3 +91,53 @@ func CreateMailbox(request CreateMailboxRequest) *shortcuts.Error {
return nil
}
+
+func EditMailboxFormData(mailboxID uint) (*EditMailboxFormResponse, *shortcuts.Error) {
+ mailbox := mailRepo.FindMailboxByIDWithRelations(mailboxID)
+ if mailbox == nil {
+ return nil, shortcuts.ServiceError(shortcuts.NotFound, MailboxNotFound)
+ }
+
+ return &EditMailboxFormResponse{
+ Mailbox: *mailbox,
+ Users: mailRepo.AllUsers(),
+ Aliases: mailRepo.FindAliasesByMailboxID(mailboxID),
+ Domains: domainRepo.AllDomains(),
+ }, nil
+}
+
+func UpdateMailbox(mailboxID uint, request UpdateMailboxRequest) *shortcuts.Error {
+ mailbox := mailRepo.FindMailboxByID(mailboxID)
+ if mailbox == nil {
+ return shortcuts.ServiceError(shortcuts.NotFound, MailboxNotFound)
+ }
+
+ if request.UserID == 0 {
+ return shortcuts.ServiceError(shortcuts.BadRequest, UserRequired)
+ }
+
+ if mailRepo.FindUserByID(request.UserID) == nil {
+ return shortcuts.ServiceError(shortcuts.Unprocessable, UserNotFound)
+ }
+
+ mailbox.UserID = request.UserID
+
+ if updateError := mailRepo.UpdateMailbox(mailbox); updateError != nil {
+ return shortcuts.ServiceError(shortcuts.Internal, MailboxUpdateFailed)
+ }
+
+ return nil
+}
+
+func DeleteMailbox(mailboxID uint) *shortcuts.Error {
+ mailbox := mailRepo.FindMailboxByID(mailboxID)
+ if mailbox == nil {
+ return shortcuts.ServiceError(shortcuts.NotFound, MailboxNotFound)
+ }
+
+ if deleteError := mailRepo.DeleteMailbox(mailbox); deleteError != nil {
+ return shortcuts.ServiceError(shortcuts.Internal, DeletionFailed)
+ }
+
+ return nil
+}
diff --git a/services/mail/messages.go b/services/mail/messages.go
index 13839e4..118b630 100644
--- a/services/mail/messages.go
+++ b/services/mail/messages.go
@@ -1,17 +1,30 @@
package mail
const (
+ AliasAddressRequired = "Alias address is required."
+ AliasAlreadyExists = "An alias with this address already exists."
+ AliasCreationFailed = "Failed to create alias."
+ AliasDeletionFailed = "Failed to delete alias."
+ AliasInvalid = "Alias address must be a valid email format ([email protected])."
+ AliasNotFound = "Alias not found."
+
LocalPartInvalid = "Local part must contain only lowercase letters, numbers, dots, hyphens, and underscores."
LocalPartRequired = "The local part of the address is required."
DomainRequired = "A domain must be selected for the mailbox."
DomainNotFound = "The selected domain does not exist."
AlreadyExists = "A mailbox with this address already exists."
CreationFailed = "Failed to create mailbox."
+ DeletionFailed = "Failed to delete mailbox."
+ MailboxNotFound = "Mailbox not found."
+ MailboxUpdateFailed = "Failed to update mailbox."
UserNotFound = "The selected user does not exist."
UserRequired = "A user must be selected for the mailbox."
DisplayNameRequired = "Display name is required."
UserAlreadyExists = "A user with this username already exists."
UserCreationFailed = "Failed to create user."
+ UserDeletionFailed = "Failed to delete user."
+ UserHasMailboxes = "Cannot delete a user that has mailboxes. Remove all mailboxes first."
+ UserUpdateFailed = "Failed to update user."
UsernameRequired = "Username is required."
)
diff --git a/services/mail/users.go b/services/mail/users.go
index 9c776a1..025c411 100644
--- a/services/mail/users.go
+++ b/services/mail/users.go
@@ -14,6 +14,14 @@ type CreateUserRequest struct {
DisplayName string `form:"display_name"`
}
+type UpdateUserRequest struct {
+ DisplayName string `form:"display_name"`
+}
+
+type EditUserFormResponse struct {
+ User mailModel.User `json:"user"`
+}
+
func ListUsers(pagination meta.Pagination, sorting meta.Sorting, search string) meta.PaginatedResponse {
users, total := mailRepo.ListUsers(pagination, sorting, search)
return pagination.Response(users, total)
@@ -47,6 +55,52 @@ func CreateUser(request CreateUserRequest) *shortcuts.Error {
return nil
}
+func EditUserFormData(userID uint) (*EditUserFormResponse, *shortcuts.Error) {
+ user := mailRepo.FindUserByID(userID)
+ if user == nil {
+ return nil, shortcuts.ServiceError(shortcuts.NotFound, UserNotFound)
+ }
+
+ return &EditUserFormResponse{User: *user}, nil
+}
+
+func UpdateUser(userID uint, request UpdateUserRequest) *shortcuts.Error {
+ user := mailRepo.FindUserByID(userID)
+ if user == nil {
+ return shortcuts.ServiceError(shortcuts.NotFound, UserNotFound)
+ }
+
+ displayName := strings.TrimSpace(request.DisplayName)
+ if displayName == "" {
+ return shortcuts.ServiceError(shortcuts.BadRequest, DisplayNameRequired)
+ }
+
+ user.DisplayName = displayName
+
+ if updateError := mailRepo.UpdateUser(user); updateError != nil {
+ return shortcuts.ServiceError(shortcuts.Internal, UserUpdateFailed)
+ }
+
+ return nil
+}
+
+func DeleteUser(userID uint) *shortcuts.Error {
+ user := mailRepo.FindUserByID(userID)
+ if user == nil {
+ return shortcuts.ServiceError(shortcuts.NotFound, UserNotFound)
+ }
+
+ if mailRepo.CountMailboxesByUserID(user.ID) > 0 {
+ return shortcuts.ServiceError(shortcuts.Unprocessable, UserHasMailboxes)
+ }
+
+ if deleteError := mailRepo.DeleteUser(user); deleteError != nil {
+ return shortcuts.ServiceError(shortcuts.Internal, UserDeletionFailed)
+ }
+
+ return nil
+}
+
func AllUsers() []mailModel.User {
return mailRepo.AllUsers()
-}
+} \ No newline at end of file
diff --git a/static/css/tailwind.css b/static/css/tailwind.css
index a325d92..2f948dc 100644
--- a/static/css/tailwind.css
+++ b/static/css/tailwind.css
@@ -308,6 +308,50 @@
background: rgba(99, 102, 241, 0.1);
}
+.alert-toast {
+ padding: 0.75rem 1rem;
+ border-radius: 0.75rem;
+ border: 1px solid rgba(239, 68, 68, 0.2);
+ background: linear-gradient(
+ 135deg,
+ rgba(239, 68, 68, 0.08),
+ rgba(239, 68, 68, 0.04)
+ );
+ backdrop-filter: blur(20px);
+ box-shadow:
+ 0 10px 40px rgba(0, 0, 0, 0.3),
+ 0 0 20px rgba(239, 68, 68, 0.05);
+ animation: alertSlideIn 0.3s ease-out;
+ min-width: 20rem;
+ max-width: 28rem;
+}
+
+.alert-toast.dismissing {
+ animation: alertSlideOut 0.2s ease-in forwards;
+}
+
+@keyframes alertSlideIn {
+ from {
+ opacity: 0;
+ transform: translateX(1rem);
+ }
+ to {
+ opacity: 1;
+ transform: translateX(0);
+ }
+}
+
+@keyframes alertSlideOut {
+ from {
+ opacity: 1;
+ transform: translateX(0);
+ }
+ to {
+ opacity: 0;
+ transform: translateX(1rem);
+ }
+}
+
::selection {
background-color: var(--color-accent-500);
color: white;
diff --git a/static/js/alert.js b/static/js/alert.js
new file mode 100644
index 0000000..4f9fc21
--- /dev/null
+++ b/static/js/alert.js
@@ -0,0 +1,28 @@
+function initAlerts() {
+ document.querySelectorAll("[data-alert]").forEach(function (alert) {
+ if (alert.dataset.alertInitialized) return;
+ alert.dataset.alertInitialized = "true";
+
+ var dismissButton = alert.querySelector("[data-alert-dismiss]");
+ var autoTimeout = setTimeout(function () {
+ dismissAlert(alert);
+ }, 5000);
+
+ if (dismissButton) {
+ dismissButton.addEventListener("click", function () {
+ clearTimeout(autoTimeout);
+ dismissAlert(alert);
+ });
+ }
+ });
+}
+
+function dismissAlert(alert) {
+ alert.classList.add("dismissing");
+ alert.addEventListener("animationend", function () {
+ alert.remove();
+ });
+}
+
+document.addEventListener("DOMContentLoaded", initAlerts);
+document.body.addEventListener("htmx:afterSwap", initAlerts);
diff --git a/static/js/confirm.js b/static/js/confirm.js
index fcc6742..6990b95 100644
--- a/static/js/confirm.js
+++ b/static/js/confirm.js
@@ -25,7 +25,7 @@ function initConfirmModals() {
modal.classList.add("hidden");
htmx.ajax("DELETE", trigger.dataset.confirmAction, {
target: "#content",
- swap: "innerHTML"
+ swap: "none"
});
});
});
diff --git a/templates/domains/editdomain.django b/templates/domains/editdomain.django
new file mode 100644
index 0000000..7b10b37
--- /dev/null
+++ b/templates/domains/editdomain.django
@@ -0,0 +1,5 @@
+{% extends "layouts/dashboard.django" %}
+
+{% block dashboard %}
+{% include "domains/htmx/editdomain.htmx.django" %}
+{% endblock %}
diff --git a/templates/domains/edittld.django b/templates/domains/edittld.django
new file mode 100644
index 0000000..a466b56
--- /dev/null
+++ b/templates/domains/edittld.django
@@ -0,0 +1,5 @@
+{% extends "layouts/dashboard.django" %}
+
+{% block dashboard %}
+{% include "domains/htmx/edittld.htmx.django" %}
+{% endblock %}
diff --git a/templates/domains/htmx/domains.htmx.django b/templates/domains/htmx/domains.htmx.django
index 82a4820..11843f6 100644
--- a/templates/domains/htmx/domains.htmx.django
+++ b/templates/domains/htmx/domains.htmx.django
@@ -21,6 +21,12 @@
</div>
<p class="text-sm text-zinc-200">{{ domain.Name }}.{{ domain.TLD.Name }}</p>
</div>
+ <div class="flex items-center gap-4">
+ {% url "domains.manage.edit" id=domain.ID as edit_domain_path %}
+ <a href="{{ edit_domain_path }}" hx-get="{{ edit_domain_path }}" hx-target="#content" hx-swap="innerHTML" hx-push-url="true" class="text-xs text-zinc-600 hover:text-zinc-300 transition-colors duration-150">Edit</a>
+ {% url "domains.manage.delete" id=domain.ID as delete_domain_path %}
+ <button data-confirm-trigger data-confirm-title="Delete {{ domain.Name }}.{{ domain.TLD.Name }}" data-confirm-message="This domain and all associated data will be permanently removed. This action cannot be undone." data-confirm-action="{{ delete_domain_path }}" class="text-xs text-zinc-600 hover:text-red-400 transition-colors duration-150">Delete</button>
+ </div>
</div>
{% endfor %}
</div>
@@ -36,4 +42,25 @@
</div>
{% endif %}
</div>
-</div> \ No newline at end of file
+
+ <div id="confirm-modal" class="fixed inset-0 z-50 hidden">
+ <div class="absolute inset-0 bg-black/60 backdrop-blur-sm" data-confirm-backdrop></div>
+ <div class="flex items-center justify-center min-h-screen p-4">
+ <div class="relative glass rounded-xl glow-border w-full max-w-sm p-6 space-y-4">
+ <div class="flex items-center gap-3">
+ <div class="flex items-center justify-center w-10 h-10 rounded-xl bg-red-500/10">
+ <svg class="w-5 h-5 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
+ </svg>
+ </div>
+ <h3 id="confirm-title" class="text-sm font-medium text-zinc-100"></h3>
+ </div>
+ <p id="confirm-message" class="text-xs text-zinc-400 leading-relaxed"></p>
+ <div class="flex items-center justify-end gap-3 pt-2">
+ <button data-confirm-cancel class="px-4 py-2 text-xs text-zinc-400 hover:text-zinc-200 rounded-lg bg-surface-800 border border-white/[0.06] hover:border-white/[0.1] transition-all duration-150">Cancel</button>
+ <button id="confirm-action" class="px-4 py-2 text-xs text-white rounded-lg bg-red-500/80 hover:bg-red-500 border border-red-400/20 transition-all duration-150">Delete</button>
+ </div>
+ </div>
+ </div>
+ </div>
+</div>
diff --git a/templates/domains/htmx/editdomain.htmx.django b/templates/domains/htmx/editdomain.htmx.django
new file mode 100644
index 0000000..f26dbce
--- /dev/null
+++ b/templates/domains/htmx/editdomain.htmx.django
@@ -0,0 +1,49 @@
+<h1 id="page-title" class="text-sm font-medium text-zinc-100" hx-swap-oob="true">{{ PageTitle }}</h1>
+<div class="slide-up flex items-start justify-center pt-12">
+ <div class="glass rounded-xl glow-border w-full max-w-lg">
+ <div class="px-5 py-4 border-b border-white/[0.04]">
+ <h2 class="text-sm font-medium text-zinc-200">Edit Domain</h2>
+ </div>
+ <div class="p-5">
+ {% url "domains.manage.update" id=domain.ID as update_path %}
+ <form hx-put="{{ update_path }}" hx-swap="none" class="space-y-4">
+ <div>
+ <label class="block text-xs font-medium text-zinc-400 mb-1.5 ml-1">Domain Name</label>
+ <input type="text" name="name" value="{{ domain.Name }}" required autocomplete="off" class="input-field">
+ </div>
+ <div>
+ <label class="block text-xs font-medium text-zinc-400 mb-1.5 ml-1">TLD</label>
+ <div class="dropdown" data-dropdown>
+ <input type="hidden" name="tld_name" value="{{ domain.TLD.Name }}" data-dropdown-value>
+ <button type="button" data-dropdown-trigger class="input-field text-left flex items-center justify-between">
+ <span class="truncate" data-dropdown-label>.{{ domain.TLD.Name }}</span>
+ <svg class="w-4 h-4 text-zinc-500 shrink-0 ml-2 transition-transform duration-150" data-dropdown-chevron fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
+ <path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
+ </svg>
+ </button>
+ <div class="dropdown-menu" data-dropdown-menu>
+ <div class="p-2 border-b border-white/[0.04]">
+ <input type="text" placeholder="Search TLDs..." class="dropdown-search" data-dropdown-search>
+ </div>
+ <div class="dropdown-options" data-dropdown-options>
+ {% for tld in tlds %}
+ <button type="button" class="dropdown-option" data-dropdown-option data-value="{{ tld.Name }}" data-label=".{{ tld.Name }}">
+ <p class="text-sm text-zinc-200">.{{ tld.Name }}</p>
+ </button>
+ {% endfor %}
+ </div>
+ <div class="dropdown-empty hidden" data-dropdown-empty>
+ <p class="text-xs text-zinc-500 text-center py-3">No TLDs found</p>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="flex items-center gap-3 pt-2">
+ <button type="submit" class="btn-primary">Save Changes</button>
+ {% url "domains.manage" as domains_path %}
+ <a href="{{ domains_path }}" hx-get="{{ domains_path }}" hx-target="#content" hx-swap="innerHTML" hx-push-url="true" class="text-xs text-zinc-500 hover:text-zinc-300 transition-colors duration-150">Cancel</a>
+ </div>
+ </form>
+ </div>
+ </div>
+</div> \ No newline at end of file
diff --git a/templates/domains/htmx/edittld.htmx.django b/templates/domains/htmx/edittld.htmx.django
new file mode 100644
index 0000000..773578b
--- /dev/null
+++ b/templates/domains/htmx/edittld.htmx.django
@@ -0,0 +1,22 @@
+<h1 id="page-title" class="text-sm font-medium text-zinc-100" hx-swap-oob="true">{{ PageTitle }}</h1>
+<div class="slide-up flex items-start justify-center pt-12">
+ <div class="glass rounded-xl glow-border w-full max-w-lg">
+ <div class="px-5 py-4 border-b border-white/[0.04]">
+ <h2 class="text-sm font-medium text-zinc-200">Edit TLD</h2>
+ </div>
+ <div class="p-5">
+ {% url "domains.tlds.update" id=tld.ID as update_path %}
+ <form hx-put="{{ update_path }}" hx-swap="none" class="space-y-4">
+ <div>
+ <label class="block text-xs font-medium text-zinc-400 mb-1.5 ml-1">TLD Name</label>
+ <input type="text" name="name" value="{{ tld.Name }}" required autocomplete="off" class="input-field">
+ </div>
+ <div class="flex items-center gap-3 pt-2">
+ <button type="submit" class="btn-primary">Save Changes</button>
+ {% url "domains.tlds" as tlds_path %}
+ <a href="{{ tlds_path }}" hx-get="{{ tlds_path }}" hx-target="#content" hx-swap="innerHTML" hx-push-url="true" class="text-xs text-zinc-500 hover:text-zinc-300 transition-colors duration-150">Cancel</a>
+ </div>
+ </form>
+ </div>
+ </div>
+</div> \ No newline at end of file
diff --git a/templates/domains/htmx/tlds.htmx.django b/templates/domains/htmx/tlds.htmx.django
index 0f4972d..13705d8 100644
--- a/templates/domains/htmx/tlds.htmx.django
+++ b/templates/domains/htmx/tlds.htmx.django
@@ -29,8 +29,12 @@
</div>
</div>
{% if not tld.IsDefault %}
- {% url "domains.tlds.delete" name=tld.Name as delete_tld_path %}
- <button data-confirm-trigger data-confirm-title="Delete .{{ tld.Name }}" data-confirm-message="This TLD and all associated data will be permanently removed. This action cannot be undone." data-confirm-action="{{ delete_tld_path }}" class="text-xs text-zinc-600 hover:text-red-400 transition-colors duration-150">Delete</button>
+ <div class="flex items-center gap-4">
+ {% url "domains.tlds.edit" id=tld.ID as edit_tld_path %}
+ <a href="{{ edit_tld_path }}" hx-get="{{ edit_tld_path }}" hx-target="#content" hx-swap="innerHTML" hx-push-url="true" class="text-xs text-zinc-600 hover:text-zinc-300 transition-colors duration-150">Edit</a>
+ {% url "domains.tlds.delete" name=tld.Name as delete_tld_path %}
+ <button data-confirm-trigger data-confirm-title="Delete .{{ tld.Name }}" data-confirm-message="This TLD and all associated data will be permanently removed. This action cannot be undone." data-confirm-action="{{ delete_tld_path }}" class="text-xs text-zinc-600 hover:text-red-400 transition-colors duration-150">Delete</button>
+ </div>
{% endif %}
</div>
{% endfor %}
diff --git a/templates/layouts/base.django b/templates/layouts/base.django
index c858bce..a5212f9 100644
--- a/templates/layouts/base.django
+++ b/templates/layouts/base.django
@@ -15,9 +15,18 @@
</div>
{% block content %}{% endblock %}
<script src="/static/js/htmx.min.js"></script>
+ <script>
+ document.body.addEventListener("htmx:beforeOnLoad", function(event) {
+ if (event.detail.xhr.status >= 400) {
+ event.detail.shouldSwap = true;
+ event.detail.isError = false;
+ }
+ });
+ </script>
<script src="/static/js/sidebar.js"></script>
<script src="/static/js/dropdown.js"></script>
<script src="/static/js/confirm.js"></script>
+ <script src="/static/js/alert.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>
diff --git a/templates/layouts/dashboard.django b/templates/layouts/dashboard.django
index b8713a6..26aad2e 100644
--- a/templates/layouts/dashboard.django
+++ b/templates/layouts/dashboard.django
@@ -11,5 +11,7 @@
{% block dashboard %}{% endblock %}
</main>
</div>
+
+ <div id="alert-container" class="fixed top-4 right-4 z-[100] flex flex-col gap-2"></div>
</div>
{% endblock %}
diff --git a/templates/mail/editmailbox.django b/templates/mail/editmailbox.django
new file mode 100644
index 0000000..e573921
--- /dev/null
+++ b/templates/mail/editmailbox.django
@@ -0,0 +1,5 @@
+{% extends "layouts/dashboard.django" %}
+
+{% block dashboard %}
+{% include "mail/htmx/editmailbox.htmx.django" %}
+{% endblock %}
diff --git a/templates/mail/edituser.django b/templates/mail/edituser.django
new file mode 100644
index 0000000..8599b50
--- /dev/null
+++ b/templates/mail/edituser.django
@@ -0,0 +1,5 @@
+{% extends "layouts/dashboard.django" %}
+
+{% block dashboard %}
+{% include "mail/htmx/edituser.htmx.django" %}
+{% endblock %}
diff --git a/templates/mail/htmx/editmailbox.htmx.django b/templates/mail/htmx/editmailbox.htmx.django
new file mode 100644
index 0000000..b25563f
--- /dev/null
+++ b/templates/mail/htmx/editmailbox.htmx.django
@@ -0,0 +1,139 @@
+<h1 id="page-title" class="text-sm font-medium text-zinc-100" hx-swap-oob="true">{{ PageTitle }}</h1>
+<div class="slide-up space-y-6 max-w-lg mx-auto pt-12">
+ <div class="glass rounded-xl glow-border">
+ <div class="px-5 py-4 border-b border-white/[0.04]">
+ <h2 class="text-sm font-medium text-zinc-200">Edit Mailbox</h2>
+ </div>
+ <div class="p-5">
+ {% url "mail.mailboxes.update" id=mailbox.ID as update_path %}
+ <form hx-put="{{ update_path }}" hx-swap="none" class="space-y-4">
+ <div>
+ <label class="block text-xs font-medium text-zinc-400 mb-1.5 ml-1">Address</label>
+ <input type="text" value="{{ mailbox.Address }}" disabled class="input-field opacity-50 cursor-not-allowed">
+ </div>
+ <div>
+ <label class="block text-xs font-medium text-zinc-400 mb-1.5 ml-1">Owner</label>
+ <div class="dropdown" data-dropdown>
+ <input type="hidden" name="user_id" value="{{ mailbox.UserID }}" data-dropdown-value>
+ <button type="button" class="input-field text-left flex items-center justify-between" data-dropdown-trigger>
+ <span class="truncate" data-dropdown-label>{{ mailbox.User.DisplayName }} ({{ mailbox.User.Username }})</span>
+ <svg class="w-4 h-4 text-zinc-500 shrink-0 ml-2 transition-transform duration-150" data-dropdown-chevron fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
+ <path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
+ </svg>
+ </button>
+ <div class="dropdown-menu" data-dropdown-menu>
+ <div class="p-2 border-b border-white/[0.04]">
+ <input type="text" placeholder="Search users..." class="dropdown-search" data-dropdown-search>
+ </div>
+ <div class="dropdown-options" data-dropdown-options>
+ {% for user in users %}
+ <button type="button" class="dropdown-option" data-dropdown-option data-value="{{ user.ID }}" data-label="{{ user.DisplayName }} ({{ user.Username }})">
+ <div>
+ <p class="text-sm text-zinc-200">{{ user.DisplayName }}</p>
+ <p class="text-xs text-zinc-500">{{ user.Username }}</p>
+ </div>
+ </button>
+ {% endfor %}
+ </div>
+ <div class="dropdown-empty hidden" data-dropdown-empty>
+ <p class="text-xs text-zinc-500 text-center py-3">No users found</p>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="flex items-center gap-3 pt-2">
+ <button type="submit" class="btn-primary">Save Changes</button>
+ {% url "mail.mailboxes" as mailboxes_path %}
+ <a href="{{ mailboxes_path }}" hx-get="{{ mailboxes_path }}" hx-target="#content" hx-swap="innerHTML" hx-push-url="true" class="text-xs text-zinc-500 hover:text-zinc-300 transition-colors duration-150">Cancel</a>
+ </div>
+ </form>
+ </div>
+ </div>
+
+ <div class="glass rounded-xl glow-border">
+ <div class="flex items-center justify-between px-5 py-4 border-b border-white/[0.04]">
+ <h2 class="text-sm font-medium text-zinc-200">Aliases</h2>
+ <span class="text-xs text-zinc-600">{{ aliases|length }} total</span>
+ </div>
+ {% if aliases %}
+ <div class="divide-y divide-white/[0.04]">
+ {% for alias in aliases %}
+ <div class="flex items-center justify-between px-5 py-3">
+ <div class="flex items-center gap-3">
+ <div class="flex items-center justify-center w-8 h-8 rounded-lg bg-purple-500/10">
+ <svg class="w-4 h-4 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" />
+ </svg>
+ </div>
+ <p class="text-sm text-zinc-200">{{ alias.SourceAddress }}</p>
+ </div>
+ {% url "mail.aliases.delete" id=mailbox.ID alias_id=alias.ID as delete_alias_path %}
+ <button data-confirm-trigger data-confirm-title="Delete {{ alias.SourceAddress }}" data-confirm-message="This alias will be permanently removed. Emails sent to this address will no longer be forwarded." data-confirm-action="{{ delete_alias_path }}" class="text-xs text-zinc-600 hover:text-red-400 transition-colors duration-150">Remove</button>
+ </div>
+ {% endfor %}
+ </div>
+ {% else %}
+ <div class="flex flex-col items-center justify-center py-8 text-center">
+ <p class="text-xs text-zinc-500">No aliases configured</p>
+ </div>
+ {% endif %}
+ <div class="px-5 py-4 border-t border-white/[0.04]">
+ {% url "mail.aliases.create" id=mailbox.ID as create_alias_path %}
+ <form hx-post="{{ create_alias_path }}" hx-swap="none" class="space-y-4">
+ <div>
+ <label class="block text-xs font-medium text-zinc-400 mb-1.5 ml-1">New Alias</label>
+ <div class="flex items-center gap-2">
+ <input type="text" name="local_part" required autocomplete="off" placeholder="alias" class="input-field flex-1">
+ <span class="text-sm text-zinc-500">@</span>
+ <div class="dropdown flex-1" data-dropdown>
+ <input type="hidden" name="domain_id" data-dropdown-value>
+ <button type="button" class="input-field text-left flex items-center justify-between" data-dropdown-trigger>
+ <span class="truncate" data-dropdown-label>Select domain</span>
+ <svg class="w-4 h-4 text-zinc-500 shrink-0 ml-2 transition-transform duration-150" data-dropdown-chevron fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
+ <path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
+ </svg>
+ </button>
+ <div class="dropdown-menu" data-dropdown-menu>
+ <div class="p-2 border-b border-white/[0.04]">
+ <input type="text" placeholder="Search domains..." class="dropdown-search" data-dropdown-search>
+ </div>
+ <div class="dropdown-options" data-dropdown-options>
+ {% for domain in domains %}
+ <button type="button" class="dropdown-option" data-dropdown-option data-value="{{ domain.ID }}" data-label="{{ domain.Name }}.{{ domain.TLD.Name }}">
+ <p class="text-sm text-zinc-200">{{ domain.Name }}.{{ domain.TLD.Name }}</p>
+ </button>
+ {% endfor %}
+ </div>
+ <div class="dropdown-empty hidden" data-dropdown-empty>
+ <p class="text-xs text-zinc-500 text-center py-3">No domains found</p>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ <button type="submit" class="btn-primary">Add Alias</button>
+ </form>
+ </div>
+ </div>
+
+ <div id="confirm-modal" class="fixed inset-0 z-50 hidden">
+ <div class="absolute inset-0 bg-black/60 backdrop-blur-sm" data-confirm-backdrop></div>
+ <div class="flex items-center justify-center min-h-screen p-4">
+ <div class="relative glass rounded-xl glow-border w-full max-w-sm p-6 space-y-4">
+ <div class="flex items-center gap-3">
+ <div class="flex items-center justify-center w-10 h-10 rounded-xl bg-red-500/10">
+ <svg class="w-5 h-5 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
+ </svg>
+ </div>
+ <h3 id="confirm-title" class="text-sm font-medium text-zinc-100"></h3>
+ </div>
+ <p id="confirm-message" class="text-xs text-zinc-400 leading-relaxed"></p>
+ <div class="flex items-center justify-end gap-3 pt-2">
+ <button data-confirm-cancel class="px-4 py-2 text-xs text-zinc-400 hover:text-zinc-200 rounded-lg bg-surface-800 border border-white/[0.06] hover:border-white/[0.1] transition-all duration-150">Cancel</button>
+ <button id="confirm-action" class="px-4 py-2 text-xs text-white rounded-lg bg-red-500/80 hover:bg-red-500 border border-red-400/20 transition-all duration-150">Delete</button>
+ </div>
+ </div>
+ </div>
+ </div>
+</div> \ No newline at end of file
diff --git a/templates/mail/htmx/edituser.htmx.django b/templates/mail/htmx/edituser.htmx.django
new file mode 100644
index 0000000..8b22afc
--- /dev/null
+++ b/templates/mail/htmx/edituser.htmx.django
@@ -0,0 +1,26 @@
+<h1 id="page-title" class="text-sm font-medium text-zinc-100" hx-swap-oob="true">{{ PageTitle }}</h1>
+<div class="slide-up flex items-start justify-center pt-12">
+ <div class="glass rounded-xl glow-border w-full max-w-lg">
+ <div class="px-5 py-4 border-b border-white/[0.04]">
+ <h2 class="text-sm font-medium text-zinc-200">Edit User</h2>
+ </div>
+ <div class="p-5">
+ {% url "mail.users.update" id=user.ID as update_path %}
+ <form hx-put="{{ update_path }}" hx-swap="none" class="space-y-4">
+ <div>
+ <label class="block text-xs font-medium text-zinc-400 mb-1.5 ml-1">Username</label>
+ <input type="text" value="{{ user.Username }}" disabled class="input-field opacity-50 cursor-not-allowed">
+ </div>
+ <div>
+ <label class="block text-xs font-medium text-zinc-400 mb-1.5 ml-1">Display Name</label>
+ <input type="text" name="display_name" value="{{ user.DisplayName }}" required autocomplete="off" class="input-field">
+ </div>
+ <div class="flex items-center gap-3 pt-2">
+ <button type="submit" class="btn-primary">Save Changes</button>
+ {% url "mail.users" as users_path %}
+ <a href="{{ users_path }}" hx-get="{{ users_path }}" hx-target="#content" hx-swap="innerHTML" hx-push-url="true" class="text-xs text-zinc-500 hover:text-zinc-300 transition-colors duration-150">Cancel</a>
+ </div>
+ </form>
+ </div>
+ </div>
+</div> \ No newline at end of file
diff --git a/templates/mail/htmx/mailboxes.htmx.django b/templates/mail/htmx/mailboxes.htmx.django
index 96094f3..c0200af 100644
--- a/templates/mail/htmx/mailboxes.htmx.django
+++ b/templates/mail/htmx/mailboxes.htmx.django
@@ -12,20 +12,28 @@
{% if items %}
<div class="divide-y divide-white/[0.04]">
{% for mailbox in items %}
- {% url "mail.mailbox" address=mailbox.Address as mailbox_path %}
- <a href="{{ mailbox_path }}" hx-get="{{ mailbox_path }}" hx-target="#content" hx-swap="innerHTML" hx-push-url="true" class="flex items-center justify-between px-5 py-3 hover:bg-white/[0.02] transition-colors duration-150">
+ <div class="flex items-center justify-between px-5 py-3">
<div class="flex items-center gap-3">
- <div class="flex items-center justify-center w-8 h-8 rounded-lg bg-accent-500/10">
- <svg class="w-4 h-4 text-accent-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
- <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 13.5h3.86a2.25 2.25 0 0 1 2.012 1.244l.256.512a2.25 2.25 0 0 0 2.013 1.244h2.21a2.25 2.25 0 0 0 2.013-1.244l.256-.512a2.25 2.25 0 0 1 2.013-1.244h3.859m-19.5.338V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18v-4.162c0-.224-.034-.447-.1-.661L19.24 5.338a2.25 2.25 0 0 0-2.15-1.588H6.911a2.25 2.25 0 0 0-2.15 1.588L2.35 13.177a2.25 2.25 0 0 0-.1.661Z" />
- </svg>
- </div>
- <div>
- <p class="text-sm text-zinc-200">{{ mailbox.Address }}</p>
- <p class="text-xs text-zinc-600">{{ mailbox.User.DisplayName }}</p>
- </div>
+ {% url "mail.mailbox" address=mailbox.Address as mailbox_path %}
+ <a href="{{ mailbox_path }}" hx-get="{{ mailbox_path }}" hx-target="#content" hx-swap="innerHTML" hx-push-url="true" class="flex items-center gap-3 hover:opacity-80 transition-opacity duration-150">
+ <div class="flex items-center justify-center w-8 h-8 rounded-lg bg-accent-500/10">
+ <svg class="w-4 h-4 text-accent-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 13.5h3.86a2.25 2.25 0 0 1 2.012 1.244l.256.512a2.25 2.25 0 0 0 2.013 1.244h2.21a2.25 2.25 0 0 0 2.013-1.244l.256-.512a2.25 2.25 0 0 1 2.013-1.244h3.859m-19.5.338V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18v-4.162c0-.224-.034-.447-.1-.661L19.24 5.338a2.25 2.25 0 0 0-2.15-1.588H6.911a2.25 2.25 0 0 0-2.15 1.588L2.35 13.177a2.25 2.25 0 0 0-.1.661Z" />
+ </svg>
+ </div>
+ <div>
+ <p class="text-sm text-zinc-200">{{ mailbox.Address }}</p>
+ <p class="text-xs text-zinc-600">{{ mailbox.User.DisplayName }}</p>
+ </div>
+ </a>
+ </div>
+ <div class="flex items-center gap-4">
+ {% url "mail.mailboxes.edit" id=mailbox.ID as edit_mailbox_path %}
+ <a href="{{ edit_mailbox_path }}" hx-get="{{ edit_mailbox_path }}" hx-target="#content" hx-swap="innerHTML" hx-push-url="true" class="text-xs text-zinc-600 hover:text-zinc-300 transition-colors duration-150">Edit</a>
+ {% url "mail.mailboxes.delete" id=mailbox.ID as delete_mailbox_path %}
+ <button data-confirm-trigger data-confirm-title="Delete {{ mailbox.Address }}" data-confirm-message="This mailbox and all its emails will be permanently removed. This action cannot be undone." data-confirm-action="{{ delete_mailbox_path }}" class="text-xs text-zinc-600 hover:text-red-400 transition-colors duration-150">Delete</button>
</div>
- </a>
+ </div>
{% endfor %}
</div>
{% else %}
@@ -36,8 +44,29 @@
</svg>
</div>
<p class="text-sm text-zinc-400">No mailboxes yet</p>
- <p class="mt-1 text-xs text-zinc-600">Send an email to start receiving mail</p>
+ <p class="mt-1 text-xs text-zinc-600">Create a mailbox to get started</p>
</div>
{% endif %}
</div>
+
+ <div id="confirm-modal" class="fixed inset-0 z-50 hidden">
+ <div class="absolute inset-0 bg-black/60 backdrop-blur-sm" data-confirm-backdrop></div>
+ <div class="flex items-center justify-center min-h-screen p-4">
+ <div class="relative glass rounded-xl glow-border w-full max-w-sm p-6 space-y-4">
+ <div class="flex items-center gap-3">
+ <div class="flex items-center justify-center w-10 h-10 rounded-xl bg-red-500/10">
+ <svg class="w-5 h-5 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
+ </svg>
+ </div>
+ <h3 id="confirm-title" class="text-sm font-medium text-zinc-100"></h3>
+ </div>
+ <p id="confirm-message" class="text-xs text-zinc-400 leading-relaxed"></p>
+ <div class="flex items-center justify-end gap-3 pt-2">
+ <button data-confirm-cancel class="px-4 py-2 text-xs text-zinc-400 hover:text-zinc-200 rounded-lg bg-surface-800 border border-white/[0.06] hover:border-white/[0.1] transition-all duration-150">Cancel</button>
+ <button id="confirm-action" class="px-4 py-2 text-xs text-white rounded-lg bg-red-500/80 hover:bg-red-500 border border-red-400/20 transition-all duration-150">Delete</button>
+ </div>
+ </div>
+ </div>
+ </div>
</div>
diff --git a/templates/mail/htmx/users.htmx.django b/templates/mail/htmx/users.htmx.django
index 7ec040c..8929907 100644
--- a/templates/mail/htmx/users.htmx.django
+++ b/templates/mail/htmx/users.htmx.django
@@ -24,7 +24,13 @@
<p class="text-xs text-zinc-600">{{ user.Username }}</p>
</div>
</div>
- <span class="text-xs text-zinc-600">{{ user.Mailboxes|length }} mailbox{{ user.Mailboxes|length|pluralize:"es" }}</span>
+ <div class="flex items-center gap-4">
+ <span class="text-xs text-zinc-600">{{ user.Mailboxes|length }} mailbox{{ user.Mailboxes|length|pluralize:"es" }}</span>
+ {% url "mail.users.edit" id=user.ID as edit_user_path %}
+ <a href="{{ edit_user_path }}" hx-get="{{ edit_user_path }}" hx-target="#content" hx-swap="innerHTML" hx-push-url="true" class="text-xs text-zinc-600 hover:text-zinc-300 transition-colors duration-150">Edit</a>
+ {% url "mail.users.delete" id=user.ID as delete_user_path %}
+ <button data-confirm-trigger data-confirm-title="Delete {{ user.DisplayName }}" data-confirm-message="This user and all associated data will be permanently removed. This action cannot be undone." data-confirm-action="{{ delete_user_path }}" class="text-xs text-zinc-600 hover:text-red-400 transition-colors duration-150">Delete</button>
+ </div>
</div>
{% endfor %}
</div>
@@ -36,8 +42,29 @@
</svg>
</div>
<p class="text-sm text-zinc-400">No users yet</p>
- <p class="mt-1 text-xs text-zinc-600">Users are created when emails are received</p>
+ <p class="mt-1 text-xs text-zinc-600">Create a user to get started</p>
</div>
{% endif %}
</div>
+
+ <div id="confirm-modal" class="fixed inset-0 z-50 hidden">
+ <div class="absolute inset-0 bg-black/60 backdrop-blur-sm" data-confirm-backdrop></div>
+ <div class="flex items-center justify-center min-h-screen p-4">
+ <div class="relative glass rounded-xl glow-border w-full max-w-sm p-6 space-y-4">
+ <div class="flex items-center gap-3">
+ <div class="flex items-center justify-center w-10 h-10 rounded-xl bg-red-500/10">
+ <svg class="w-5 h-5 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
+ </svg>
+ </div>
+ <h3 id="confirm-title" class="text-sm font-medium text-zinc-100"></h3>
+ </div>
+ <p id="confirm-message" class="text-xs text-zinc-400 leading-relaxed"></p>
+ <div class="flex items-center justify-end gap-3 pt-2">
+ <button data-confirm-cancel class="px-4 py-2 text-xs text-zinc-400 hover:text-zinc-200 rounded-lg bg-surface-800 border border-white/[0.06] hover:border-white/[0.1] transition-all duration-150">Cancel</button>
+ <button id="confirm-action" class="px-4 py-2 text-xs text-white rounded-lg bg-red-500/80 hover:bg-red-500 border border-red-400/20 transition-all duration-150">Delete</button>
+ </div>
+ </div>
+ </div>
+ </div>
</div>
diff --git a/templates/partials/alert.django b/templates/partials/alert.django
new file mode 100644
index 0000000..c2c8052
--- /dev/null
+++ b/templates/partials/alert.django
@@ -0,0 +1,17 @@
+<div id="alert-container" class="fixed top-4 right-4 z-[100] flex flex-col gap-2" hx-swap-oob="true">
+ <div class="alert-toast" data-alert>
+ <div class="flex items-center gap-3">
+ <div class="flex items-center justify-center w-8 h-8 rounded-lg bg-red-500/10 shrink-0">
+ <svg class="w-4 h-4 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z" />
+ </svg>
+ </div>
+ <p class="text-sm text-zinc-200">{{ ErrorMessage }}</p>
+ <button data-alert-dismiss class="ml-auto text-zinc-500 hover:text-zinc-300 transition-colors duration-150 shrink-0">
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
+ </svg>
+ </button>
+ </div>
+ </div>
+</div>
diff --git a/utils/shortcuts/error.go b/utils/shortcuts/error.go
index c22e6b2..ae6cec3 100644
--- a/utils/shortcuts/error.go
+++ b/utils/shortcuts/error.go
@@ -38,42 +38,77 @@ func ServiceError(kind ErrorKind, message string) *Error {
}
}
+func isHtmxRequest(context *fiber.Ctx) bool {
+ return context.Get("HX-Request") == "true" && context.Get("HX-Boosted") != "true"
+}
+
+func renderAlert(context *fiber.Ctx, message string, statusCode int) error {
+ context.Status(statusCode)
+ return context.Render("partials/alert", fiber.Map{
+ "ErrorMessage": message,
+ })
+}
+
func HandleError(context *fiber.Ctx, serviceError *Error) error {
statusCode, exists := statusMap[serviceError.Kind]
if !exists {
statusCode = fiber.StatusInternalServerError
}
+ if isHtmxRequest(context) {
+ return renderAlert(context, serviceError.Message, statusCode)
+ }
+
return RenderWithStatus(context, "error", fiber.Map{
"ErrorMessage": serviceError.Message,
}, statusCode)
}
func BadRequestError(context *fiber.Ctx, err error) error {
+ if isHtmxRequest(context) {
+ return renderAlert(context, err.Error(), fiber.StatusBadRequest)
+ }
+
return RenderWithStatus(context, "error", fiber.Map{
"ErrorMessage": err.Error(),
}, fiber.StatusBadRequest)
}
func ForbiddenError(context *fiber.Ctx, err error) error {
+ if isHtmxRequest(context) {
+ return renderAlert(context, err.Error(), fiber.StatusForbidden)
+ }
+
return RenderWithStatus(context, "error", fiber.Map{
"ErrorMessage": err.Error(),
}, fiber.StatusForbidden)
}
func InternalServerError(context *fiber.Ctx, err error) error {
+ if isHtmxRequest(context) {
+ return renderAlert(context, err.Error(), fiber.StatusInternalServerError)
+ }
+
return RenderWithStatus(context, "error", fiber.Map{
"ErrorMessage": err.Error(),
}, fiber.StatusInternalServerError)
}
func NotFoundError(context *fiber.Ctx, err error) error {
+ if isHtmxRequest(context) {
+ return renderAlert(context, err.Error(), fiber.StatusNotFound)
+ }
+
return RenderWithStatus(context, "error", fiber.Map{
"ErrorMessage": err.Error(),
}, fiber.StatusNotFound)
}
func UnauthorizedError(context *fiber.Ctx, err error) error {
+ if isHtmxRequest(context) {
+ return renderAlert(context, err.Error(), fiber.StatusUnauthorized)
+ }
+
return RenderWithStatus(context, "error", fiber.Map{
"ErrorMessage": err.Error(),
}, fiber.StatusUnauthorized)
diff --git a/utils/shortcuts/redirect.go b/utils/shortcuts/redirect.go
index 627538d..8b2bb1d 100644
--- a/utils/shortcuts/redirect.go
+++ b/utils/shortcuts/redirect.go
@@ -12,14 +12,33 @@ func Redirect(context *fiber.Ctx, routeName string) error {
return fiber.ErrNotFound
}
+ if isHtmxRequest(context) {
+ context.Set("HX-Redirect", fullPath)
+ return context.SendStatus(fiber.StatusNoContent)
+ }
+
return context.Redirect(fullPath)
}
+func RedirectToPath(context *fiber.Ctx, path string) error {
+ if isHtmxRequest(context) {
+ context.Set("HX-Redirect", path)
+ return context.SendStatus(fiber.StatusNoContent)
+ }
+
+ return context.Redirect(path)
+}
+
func RedirectWithStatus(context *fiber.Ctx, routeName string, statusCode int) error {
fullPath, exists := urls.GetFullPath(routeName)
if !exists {
return fiber.ErrNotFound
}
+ if isHtmxRequest(context) {
+ context.Set("HX-Redirect", fullPath)
+ return context.SendStatus(fiber.StatusNoContent)
+ }
+
return context.Redirect(fullPath, statusCode)
-} \ No newline at end of file
+}