diff options
| author | Bobby <[email protected]> | 2026-03-08 05:43:04 +0530 |
|---|---|---|
| committer | Bobby <[email protected]> | 2026-03-08 05:43:04 +0530 |
| commit | 662dd2069dc8590e8b54823a33726464cf10c4e7 (patch) | |
| tree | 55a740e6114440d7e311afd3f5ba79a7101965f8 | |
| parent | d21ea918864a8b18fef94bbfaec8097444be1b17 (diff) | |
| download | dove-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.
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 +} |
