diff options
| author | Bobby <[email protected]> | 2026-03-08 04:00:38 +0530 |
|---|---|---|
| committer | Bobby <[email protected]> | 2026-03-08 04:00:38 +0530 |
| commit | 44e056c26936b302478fa4e64e3f8e3e6a9a30cf (patch) | |
| tree | 15546500b74b10fcb741727440666f2f5167c174 | |
| parent | caf265e7050edefa64ecf7e13828ec9636bce867 (diff) | |
| download | dove-44e056c26936b302478fa4e64e3f8e3e6a9a30cf.tar.xz dove-44e056c26936b302478fa4e64e3f8e3e6a9a30cf.zip | |
feat: Enhance mail and domain management UI and functionality
- Updated users page to include a help link with an icon.
- Refactored sidebar navigation to improve organization and added collapsible sections for Domains and Mail.
- Created new pages for managing domains and TLDs, including a detailed description of the Domain Manager.
- Implemented confirmation modals for deleting TLDs with appropriate messaging.
- Added JavaScript functionality for sidebar state management and confirmation modals.
- Introduced new Go handlers for mail index and domain management.
- Added validation functions for DNS labels and email local parts.
34 files changed, 527 insertions, 133 deletions
diff --git a/controllers/domain/domain.go b/controllers/domain/domain.go index 1260e3d..5613ade 100644 --- a/controllers/domain/domain.go +++ b/controllers/domain/domain.go @@ -19,7 +19,7 @@ func CreateDomain(context *fiber.Ctx) error { return shortcuts.HandleError(context, serviceError) } - return shortcuts.Redirect(context, "domains.index") + return shortcuts.Redirect(context, "domains.manage") } func CreateTLD(context *fiber.Ctx) error { @@ -33,7 +33,7 @@ func CreateTLD(context *fiber.Ctx) error { return shortcuts.HandleError(context, serviceError) } - return shortcuts.Redirect(context, "domains.index") + return shortcuts.Redirect(context, "domains.tlds") } func DeleteTLD(context *fiber.Ctx) error { @@ -42,5 +42,5 @@ func DeleteTLD(context *fiber.Ctx) error { return shortcuts.HandleError(context, serviceError) } - return shortcuts.Redirect(context, "domains.index") + return shortcuts.Redirect(context, "domains.tlds") }
\ No newline at end of file diff --git a/controllers/mail/mail.go b/controllers/mail/mail.go index 976076b..544a51b 100644 --- a/controllers/mail/mail.go +++ b/controllers/mail/mail.go @@ -19,7 +19,7 @@ func CreateUser(context *fiber.Ctx) error { return shortcuts.HandleError(context, serviceError) } - return shortcuts.Redirect(context, "dashboard.users") + return shortcuts.Redirect(context, "mail.users") } func CreateMailbox(context *fiber.Ctx) error { @@ -33,5 +33,5 @@ func CreateMailbox(context *fiber.Ctx) error { return shortcuts.HandleError(context, serviceError) } - return shortcuts.Redirect(context, "dashboard.mailboxes") + return shortcuts.Redirect(context, "mail.mailboxes") }
\ No newline at end of file diff --git a/models/mail/mailbox.go b/models/mail/mailbox.go index 62e3f1d..93aa5d1 100644 --- a/models/mail/mailbox.go +++ b/models/mail/mailbox.go @@ -1,12 +1,17 @@ package mail -import "gorm.io/gorm" +import ( + domainModel "dove/models/domain" + "gorm.io/gorm" +) type Mailbox struct { gorm.Model - Address string `gorm:"uniqueIndex;not null" json:"address"` - UserID uint `gorm:"not null" json:"user_id"` - User User `gorm:"foreignKey:UserID" json:"user"` - Aliases []Alias `gorm:"foreignKey:MailboxID" json:"aliases"` - Emails []Email `gorm:"foreignKey:MailboxID" json:"emails"` + Address string `gorm:"uniqueIndex;not null" json:"address"` + UserID uint `gorm:"not null" json:"user_id"` + User User `gorm:"foreignKey:UserID" json:"user"` + DomainID uint `gorm:"not null" json:"domain_id"` + Domain domainModel.Domain `gorm:"foreignKey:DomainID" json:"domain"` + Aliases []Alias `gorm:"foreignKey:MailboxID" json:"aliases"` + Emails []Email `gorm:"foreignKey:MailboxID" json:"emails"` } diff --git a/pages/domain/domain.go b/pages/domain/domain.go index 3d01df0..cb1022b 100644 --- a/pages/domain/domain.go +++ b/pages/domain/domain.go @@ -8,6 +8,16 @@ import ( "github.com/gofiber/fiber/v2" ) +func Index(context *fiber.Ctx) error { + meta.SetPageTitle(context, "Domain Manager") + return shortcuts.Render(context, "domains/index", nil) +} + +func TLDs(context *fiber.Ctx) error { + meta.SetPageTitle(context, "TLDs") + return shortcuts.Render(context, "domains/tlds", domainService.ListTLDs()) +} + func Domains(context *fiber.Ctx) error { meta.SetPageTitle(context, "Domains") return shortcuts.Render(context, "domains/domains", domainService.ListDomains()) diff --git a/pages/mail/index.go b/pages/mail/index.go new file mode 100644 index 0000000..eb0113e --- /dev/null +++ b/pages/mail/index.go @@ -0,0 +1,13 @@ +package mail + +import ( + "dove/utils/meta" + "dove/utils/shortcuts" + + "github.com/gofiber/fiber/v2" +) + +func Index(context *fiber.Ctx) error { + meta.SetPageTitle(context, "Mail") + return shortcuts.Render(context, "mail/index", nil) +} diff --git a/repositories/domain/domain.go b/repositories/domain/domain.go index 406e272..5fe7778 100644 --- a/repositories/domain/domain.go +++ b/repositories/domain/domain.go @@ -13,6 +13,15 @@ func AllDomains() []domain.Domain { return domains } +func FindDomainByID(domainID uint) *domain.Domain { + var foundDomain domain.Domain + result := database.DB.Preload("TLD").First(&foundDomain, domainID) + if result.Error == gorm.ErrRecordNotFound { + return nil + } + return &foundDomain +} + func FindDomainByFullName(name string, tldName string) *domain.Domain { var foundDomain domain.Domain result := database.DB. @@ -36,3 +45,9 @@ func UpdateDomain(updatedDomain *domain.Domain) error { func DeleteDomain(targetDomain *domain.Domain) error { return database.DB.Delete(targetDomain).Error } + +func CountDomainsByTLDID(tldID uint) int64 { + var count int64 + database.DB.Model(&domain.Domain{}).Where("tld_id = ?", tldID).Count(&count) + return count +} diff --git a/repositories/mail/mailbox.go b/repositories/mail/mailbox.go index 56025a6..1bfb607 100644 --- a/repositories/mail/mailbox.go +++ b/repositories/mail/mailbox.go @@ -34,7 +34,7 @@ func ListMailboxes(pagination meta.Pagination, sorting meta.Sorting, search stri } query.Count(&total) - pagination.Apply(sorting.Apply(query)).Preload("User").Find(&mailboxes) + pagination.Apply(sorting.Apply(query)).Preload("User").Preload("Domain").Preload("Domain.TLD").Find(&mailboxes) return mailboxes, total } diff --git a/router/domain.go b/router/domain.go index 5658cf2..50b1163 100644 --- a/router/domain.go +++ b/router/domain.go @@ -10,10 +10,12 @@ import ( func init() { urls.SetNamespace("domains") - urls.Path(urls.Get, "/domains", auth.RequireAuthentication(domainPage.Domains), "index") - urls.Path(urls.Get, "/domains/new", auth.RequireAuthentication(domainPage.NewDomain), "new") - urls.Path(urls.Post, "/domains", auth.RequireAuthentication(domainController.CreateDomain), "create") - urls.Path(urls.Get, "/domains/tlds/new", auth.RequireAuthentication(domainPage.NewTLD), "tlds.new") - urls.Path(urls.Post, "/domains/tlds", auth.RequireAuthentication(domainController.CreateTLD), "tlds.create") - urls.Path(urls.Delete, "/domains/tlds/:name", auth.RequireAuthentication(domainController.DeleteTLD), "tlds.delete") + urls.Path(urls.Get, "/", auth.RequireAuthentication(domainPage.Index), "index") + 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.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") } diff --git a/router/mail.go b/router/mail.go index f2ce1a7..e94f470 100644 --- a/router/mail.go +++ b/router/mail.go @@ -10,6 +10,7 @@ import ( func init() { urls.SetNamespace("mail") + urls.Path(urls.Get, "/", auth.RequireAuthentication(mailPage.Index), "index") 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") diff --git a/services/domain/domain.go b/services/domain/domain.go index 39d5894..957bd36 100644 --- a/services/domain/domain.go +++ b/services/domain/domain.go @@ -6,6 +6,7 @@ import ( domainModel "dove/models/domain" domainRepo "dove/repositories/domain" "dove/utils/shortcuts" + "dove/utils/validate" ) type CreateDomainRequest struct { @@ -15,17 +16,25 @@ type CreateDomainRequest struct { type DomainListResponse struct { Domains []domainModel.Domain `json:"domains"` - TLDs []domainModel.TLD `json:"tlds"` +} + +type TLDListResponse struct { + TLDs []domainModel.TLD `json:"tlds"` } type DomainFormResponse struct { TLDs []domainModel.TLD `json:"tlds"` } +func ListTLDs() TLDListResponse { + return TLDListResponse{ + TLDs: domainRepo.AllTLDs(), + } +} + func ListDomains() DomainListResponse { return DomainListResponse{ Domains: domainRepo.AllDomains(), - TLDs: domainRepo.AllTLDs(), } } @@ -42,6 +51,8 @@ func CreateDomain(request CreateDomainRequest) *shortcuts.Error { 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) } diff --git a/services/domain/messages.go b/services/domain/messages.go index 5e42a3a..70be560 100644 --- a/services/domain/messages.go +++ b/services/domain/messages.go @@ -3,12 +3,15 @@ package domain const ( DomainAlreadyExists = "A domain with this name already exists under this TLD." DomainCreationFailed = "Failed to create domain." + DomainNameInvalid = "Domain name must contain only lowercase letters, numbers, and hyphens." DomainNameRequired = "Domain name is required." DomainNotFound = "Domain not found." DomainTLDRequired = "A TLD must be selected for the domain." TLDAlreadyExists = "A TLD with this name already exists." TLDCreationFailed = "Failed to create TLD." TLDDeletionFailed = "Failed to delete TLD." + TLDHasDomains = "Cannot delete a TLD that has registered domains. Remove all domains first." + TLDNameInvalid = "TLD name must contain only lowercase letters, numbers, and hyphens." TLDNameRequired = "TLD name is required." TLDNotFound = "TLD not found." TLDProtected = "Default TLDs cannot be deleted." diff --git a/services/domain/tld.go b/services/domain/tld.go index eb80c14..d9140b7 100644 --- a/services/domain/tld.go +++ b/services/domain/tld.go @@ -6,6 +6,7 @@ import ( domainModel "dove/models/domain" domainRepo "dove/repositories/domain" "dove/utils/shortcuts" + "dove/utils/validate" ) type CreateTLDRequest struct { @@ -22,6 +23,8 @@ func CreateTLD(request CreateTLDRequest) *shortcuts.Error { switch { case name == "": return shortcuts.ServiceError(shortcuts.BadRequest, TLDNameRequired) + case !validate.DNSLabel(name): + return shortcuts.ServiceError(shortcuts.BadRequest, TLDNameInvalid) case domainRepo.FindTLDByName(name) != nil: return shortcuts.ServiceError(shortcuts.Unprocessable, TLDAlreadyExists) } @@ -46,6 +49,8 @@ func DeleteTLD(name string) *shortcuts.Error { return shortcuts.ServiceError(shortcuts.NotFound, TLDNotFound) case tld.IsDefault: return shortcuts.ServiceError(shortcuts.Forbidden, TLDProtected) + case domainRepo.CountDomainsByTLDID(tld.ID) > 0: + return shortcuts.ServiceError(shortcuts.Unprocessable, TLDHasDomains) } if deleteError := domainRepo.DeleteTLD(tld); deleteError != nil { diff --git a/services/mail/mailboxes.go b/services/mail/mailboxes.go index 89af4c4..041c64b 100644 --- a/services/mail/mailboxes.go +++ b/services/mail/mailboxes.go @@ -3,19 +3,24 @@ package mail import ( "strings" + domainModel "dove/models/domain" + domainRepo "dove/repositories/domain" mailModel "dove/models/mail" mailRepo "dove/repositories/mail" "dove/utils/meta" "dove/utils/shortcuts" + "dove/utils/validate" ) type CreateMailboxRequest struct { - Address string `form:"address"` - UserID uint `form:"user_id"` + LocalPart string `form:"local_part"` + DomainID uint `form:"domain_id"` + UserID uint `form:"user_id"` } type MailboxFormResponse struct { - Users []mailModel.User `json:"users"` + Users []mailModel.User `json:"users"` + Domains []domainModel.Domain `json:"domains"` } type MailboxView struct { @@ -29,16 +34,21 @@ func ListMailboxes(pagination meta.Pagination, sorting meta.Sorting, search stri func MailboxFormData() MailboxFormResponse { return MailboxFormResponse{ - Users: mailRepo.AllUsers(), + Users: mailRepo.AllUsers(), + Domains: domainRepo.AllDomains(), } } func CreateMailbox(request CreateMailboxRequest) *shortcuts.Error { - address := strings.TrimSpace(request.Address) + localPart := strings.TrimSpace(request.LocalPart) switch { - case address == "": - return shortcuts.ServiceError(shortcuts.BadRequest, AddressRequired) + 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.UserID == 0: return shortcuts.ServiceError(shortcuts.BadRequest, UserRequired) } @@ -47,13 +57,21 @@ func CreateMailbox(request CreateMailboxRequest) *shortcuts.Error { return shortcuts.ServiceError(shortcuts.Unprocessable, UserNotFound) } + foundDomain := domainRepo.FindDomainByID(request.DomainID) + if foundDomain == nil { + return shortcuts.ServiceError(shortcuts.Unprocessable, DomainNotFound) + } + + address := localPart + "@" + foundDomain.Name + "." + foundDomain.TLD.Name + if mailRepo.FindMailboxByAddress(address) != nil { return shortcuts.ServiceError(shortcuts.Unprocessable, AlreadyExists) } mailbox := &mailModel.Mailbox{ - Address: address, - UserID: request.UserID, + Address: address, + UserID: request.UserID, + DomainID: request.DomainID, } if createError := mailRepo.CreateMailbox(mailbox); createError != nil { diff --git a/services/mail/messages.go b/services/mail/messages.go index 300f716..13839e4 100644 --- a/services/mail/messages.go +++ b/services/mail/messages.go @@ -1,11 +1,14 @@ package mail const ( - AddressRequired = "Mailbox address is required." - AlreadyExists = "A mailbox with this address already exists." - CreationFailed = "Failed to create mailbox." - UserNotFound = "The selected user does not exist." - UserRequired = "A user must be selected for the mailbox." + 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." + 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." diff --git a/static/js/confirm.js b/static/js/confirm.js new file mode 100644 index 0000000..fcc6742 --- /dev/null +++ b/static/js/confirm.js @@ -0,0 +1,43 @@ +function initConfirmModals() { + var modal = document.getElementById("confirm-modal"); + if (!modal) return; + + var title = document.getElementById("confirm-title"); + var message = document.getElementById("confirm-message"); + var actionButton = document.getElementById("confirm-action"); + var backdrop = modal.querySelector("[data-confirm-backdrop]"); + var cancelButton = modal.querySelector("[data-confirm-cancel]"); + + document.querySelectorAll("[data-confirm-trigger]").forEach(function (trigger) { + if (trigger.dataset.confirmInitialized) return; + trigger.dataset.confirmInitialized = "true"; + + trigger.addEventListener("click", function () { + title.textContent = trigger.dataset.confirmTitle; + message.textContent = trigger.dataset.confirmMessage; + modal.classList.remove("hidden"); + + var cloned = actionButton.cloneNode(true); + actionButton.parentNode.replaceChild(cloned, actionButton); + actionButton = cloned; + + actionButton.addEventListener("click", function () { + modal.classList.add("hidden"); + htmx.ajax("DELETE", trigger.dataset.confirmAction, { + target: "#content", + swap: "innerHTML" + }); + }); + }); + }); + + function closeModal() { + modal.classList.add("hidden"); + } + + backdrop.addEventListener("click", closeModal); + cancelButton.addEventListener("click", closeModal); +} + +document.addEventListener("DOMContentLoaded", initConfirmModals); +document.body.addEventListener("htmx:afterSwap", initConfirmModals);
\ No newline at end of file diff --git a/static/js/navigation.js b/static/js/navigation.js deleted file mode 100644 index b2d293c..0000000 --- a/static/js/navigation.js +++ /dev/null @@ -1,10 +0,0 @@ -document.body.addEventListener("htmx:afterSwap", function () { - var currentPath = window.location.pathname; - document.querySelectorAll("#sidebar-nav .nav-link").forEach(function (link) { - if (link.getAttribute("href") === currentPath) { - link.classList.add("active"); - } else { - link.classList.remove("active"); - } - }); -});
\ No newline at end of file diff --git a/static/js/sidebar.js b/static/js/sidebar.js new file mode 100644 index 0000000..6a889e4 --- /dev/null +++ b/static/js/sidebar.js @@ -0,0 +1,75 @@ +var sidebarStorageKey = "dove-sidebar-expanded"; + +function getSidebarState() { + try { + var stored = localStorage.getItem(sidebarStorageKey); + return stored ? JSON.parse(stored) : {}; + } catch (error) { + return {}; + } +} + +function saveSidebarState(state) { + localStorage.setItem(sidebarStorageKey, JSON.stringify(state)); +} + +function toggleSection(section, expanded) { + var children = section.querySelector("[data-sidebar-children]"); + var chevron = section.querySelector("[data-sidebar-chevron]"); + + if (expanded) { + children.classList.remove("hidden"); + chevron.style.transform = "rotate(90deg)"; + } else { + children.classList.add("hidden"); + chevron.style.transform = ""; + } +} + +function initSidebar() { + var currentPath = window.location.pathname; + var state = getSidebarState(); + + document.querySelectorAll("#sidebar-nav > a.nav-link").forEach(function (link) { + link.classList.remove("active"); + if (link.getAttribute("href") === currentPath) { + link.classList.add("active"); + } + }); + + document.querySelectorAll("[data-sidebar-section]").forEach(function (section) { + var prefix = section.dataset.sectionPrefix; + var trigger = section.querySelector("[data-sidebar-trigger]"); + var pathMatchesSection = currentPath.indexOf(prefix) === 0; + + if (pathMatchesSection && state[prefix] === undefined) { + state[prefix] = true; + } + + toggleSection(section, !!state[prefix]); + + section.querySelectorAll("[data-sidebar-children] .nav-link").forEach(function (link) { + link.classList.remove("active"); + var href = link.getAttribute("href"); + if (currentPath.indexOf(href) === 0 && href !== prefix + "/") { + link.classList.add("active"); + } + }); + + if (!trigger.dataset.sidebarBound) { + trigger.dataset.sidebarBound = "true"; + trigger.addEventListener("click", function (event) { + event.preventDefault(); + event.stopPropagation(); + state[prefix] = !state[prefix]; + saveSidebarState(state); + toggleSection(section, state[prefix]); + }); + } + }); + + saveSidebarState(state); +} + +document.addEventListener("DOMContentLoaded", initSidebar); +document.body.addEventListener("htmx:afterSwap", initSidebar); diff --git a/templates/domains/htmx/domains.htmx.django b/templates/domains/htmx/domains.htmx.django index 42fd320..bbd8a34 100644 --- a/templates/domains/htmx/domains.htmx.django +++ b/templates/domains/htmx/domains.htmx.django @@ -1,51 +1,12 @@ -<h1 id="page-title" class="text-sm font-medium text-zinc-100" hx-swap-oob="true">{{ PageTitle }}</h1> +{% url "domains.index" as help_path %} +<h1 id="page-title" class="text-sm font-medium text-zinc-100 flex items-center gap-2" hx-swap-oob="true">{{ PageTitle }}<a href="{{ help_path }}" hx-get="{{ help_path }}" hx-target="#content" hx-swap="innerHTML" hx-push-url="true" class="text-zinc-600 hover:text-zinc-400 transition-colors duration-150"><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="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 5.25h.008v.008H12v-.008Z" /></svg></a></h1> <div class="slide-up space-y-6"> <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">TLDs</h2> - <div class="flex items-center gap-3"> - <span class="text-xs text-zinc-600">{{ tlds|length }} total</span> - {% url "domains.tlds.new" as new_tld_path %} - <a href="{{ new_tld_path }}" hx-get="{{ new_tld_path }}" hx-target="#content" hx-swap="innerHTML" hx-push-url="true" class="btn-small">New TLD</a> - </div> - </div> - {% if tlds %} - <div class="divide-y divide-white/[0.04]"> - {% for tld in tlds %} - <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="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418" /> - </svg> - </div> - <div> - <p class="text-sm text-zinc-200">.{{ tld.Name }}</p> - {% if tld.IsDefault %} - <p class="text-xs text-zinc-600">Default</p> - {% endif %} - </div> - </div> - {% if not tld.IsDefault %} - {% url "domains.tlds.delete" name=tld.Name as delete_tld_path %} - <button hx-delete="{{ delete_tld_path }}" hx-confirm="Delete .{{ tld.Name }}?" hx-target="#content" hx-swap="innerHTML" class="text-xs text-zinc-600 hover:text-red-400 transition-colors duration-150">Delete</button> - {% endif %} - </div> - {% endfor %} - </div> - {% else %} - <div class="flex flex-col items-center justify-center py-16 text-center"> - <p class="text-sm text-zinc-400">No TLDs configured</p> - </div> - {% endif %} - </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">Domains</h2> <div class="flex items-center gap-3"> <span class="text-xs text-zinc-600">{{ domains|length }} total</span> - {% url "domains.new" as new_domain_path %} + {% url "domains.manage.new" as new_domain_path %} <a href="{{ new_domain_path }}" hx-get="{{ new_domain_path }}" hx-target="#content" hx-swap="innerHTML" hx-push-url="true" class="btn-small">New Domain</a> </div> </div> diff --git a/templates/domains/htmx/index.htmx.django b/templates/domains/htmx/index.htmx.django new file mode 100644 index 0000000..aa96fda --- /dev/null +++ b/templates/domains/htmx/index.htmx.django @@ -0,0 +1,32 @@ +<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"> + <div class="glass rounded-xl glow-border"> + <div class="px-6 py-5 border-b border-white/[0.04]"> + <div class="flex items-center gap-3 mb-4"> + <div class="flex items-center justify-center w-10 h-10 rounded-xl bg-accent-500/10"> + <svg class="w-5 h-5 text-accent-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> + <path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418" /> + </svg> + </div> + <h2 class="text-base font-medium text-zinc-100">Domain Manager</h2> + </div> + <p class="text-sm text-zinc-400 leading-relaxed">The Domain Manager provides local DNS infrastructure for your development environment. It allows you to create custom top-level domains and register domain names that resolve locally, giving your services clean, memorable addresses without modifying your system hosts file.</p> + </div> + <div class="px-6 py-5 space-y-6"> + <div> + <h3 class="text-sm font-medium text-zinc-200 mb-2">Top-Level Domains (TLDs)</h3> + <p class="text-xs text-zinc-500 leading-relaxed mb-2">TLDs are the root of your local domain hierarchy. Dove ships with default TLDs like <span class="text-zinc-400">local</span> and <span class="text-zinc-400">dev</span>, and you can create custom ones to match your organisation or project structure.</p> + <p class="text-xs text-zinc-500 leading-relaxed">Default TLDs are protected and cannot be deleted. Custom TLDs can only be removed once all domains registered under them have been deleted first.</p> + </div> + <div class="border-t border-white/[0.04] pt-5"> + <h3 class="text-sm font-medium text-zinc-200 mb-2">Domains</h3> + <p class="text-xs text-zinc-500 leading-relaxed mb-2">Domains are registered under a TLD and serve as the address for your local services. For example, registering <span class="text-zinc-400">myapp</span> under <span class="text-zinc-400">local</span> gives you <span class="text-zinc-400">myapp.local</span>.</p> + <p class="text-xs text-zinc-500 leading-relaxed">Each domain can be mapped to local services through DNS A records with port support, similar to how Cloudflare handles traffic routing. This means your services get proper domain names that resolve to the correct local ports.</p> + </div> + <div class="border-t border-white/[0.04] pt-5"> + <h3 class="text-sm font-medium text-zinc-200 mb-2">How It Works</h3> + <p class="text-xs text-zinc-500 leading-relaxed">Dove runs a local DNS server that intercepts queries for your registered TLDs and returns the appropriate local addresses. External domains continue to resolve normally through your upstream DNS. No system configuration changes are required beyond pointing your DNS resolver at Dove.</p> + </div> + </div> + </div> +</div>
\ No newline at end of file diff --git a/templates/domains/htmx/newdomain.htmx.django b/templates/domains/htmx/newdomain.htmx.django index 5f188c1..9f0f20a 100644 --- a/templates/domains/htmx/newdomain.htmx.django +++ b/templates/domains/htmx/newdomain.htmx.django @@ -1,11 +1,12 @@ -<h1 id="page-title" class="text-sm font-medium text-zinc-100" hx-swap-oob="true">{{ PageTitle }}</h1> +{% url "domains.index" as help_path %} +<h1 id="page-title" class="text-sm font-medium text-zinc-100 flex items-center gap-2" hx-swap-oob="true">{{ PageTitle }}<a href="{{ help_path }}" hx-get="{{ help_path }}" hx-target="#content" hx-swap="innerHTML" hx-push-url="true" class="text-zinc-600 hover:text-zinc-400 transition-colors duration-150"><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="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 5.25h.008v.008H12v-.008Z" /></svg></a></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">Register Domain</h2> </div> <div class="p-5"> - {% url "domains.create" as create_path %} + {% url "domains.manage.create" as create_path %} <form method="POST" action="{{ create_path }}" class="space-y-4"> <div> <label class="block text-xs font-medium text-zinc-400 mb-1.5 ml-1">Domain Name</label> @@ -13,27 +14,34 @@ </div> <div> <label class="block text-xs font-medium text-zinc-400 mb-1.5 ml-1">TLD</label> - <div data-dropdown> + <div class="dropdown" data-dropdown> <input type="hidden" name="tld_name" data-dropdown-value> <button type="button" data-dropdown-trigger class="input-field text-left flex items-center justify-between"> - <span data-dropdown-label class="text-zinc-500">Select a TLD</span> - <svg class="w-4 h-4 text-zinc-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> + <span class="truncate" data-dropdown-label>Select a TLD</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 data-dropdown-menu class="dropdown-menu"> - <input type="text" data-dropdown-search class="dropdown-search" placeholder="Search TLDs..."> - <div data-dropdown-options class="dropdown-options"> + <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 %} - <div data-dropdown-option data-value="{{ tld.Name }}" class="dropdown-option">.{{ tld.Name }}</div> + <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">Register Domain</button> - {% url "domains.index" as domains_path %} + {% 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> diff --git a/templates/domains/htmx/newtld.htmx.django b/templates/domains/htmx/newtld.htmx.django index 39d09d2..9de64e1 100644 --- a/templates/domains/htmx/newtld.htmx.django +++ b/templates/domains/htmx/newtld.htmx.django @@ -1,4 +1,5 @@ -<h1 id="page-title" class="text-sm font-medium text-zinc-100" hx-swap-oob="true">{{ PageTitle }}</h1> +{% url "domains.index" as help_path %} +<h1 id="page-title" class="text-sm font-medium text-zinc-100 flex items-center gap-2" hx-swap-oob="true">{{ PageTitle }}<a href="{{ help_path }}" hx-get="{{ help_path }}" hx-target="#content" hx-swap="innerHTML" hx-push-url="true" class="text-zinc-600 hover:text-zinc-400 transition-colors duration-150"><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="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 5.25h.008v.008H12v-.008Z" /></svg></a></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]"> @@ -9,15 +10,12 @@ <form method="POST" action="{{ create_path }}" class="space-y-4"> <div> <label class="block text-xs font-medium text-zinc-400 mb-1.5 ml-1">TLD Name</label> - <div class="flex items-center gap-2"> - <span class="text-sm text-zinc-500">.</span> - <input type="text" name="name" required autocomplete="off" placeholder="example" class="input-field"> - </div> + <input type="text" name="name" required autocomplete="off" placeholder="example" class="input-field"> </div> <div class="flex items-center gap-3 pt-2"> <button type="submit" class="btn-primary">Create TLD</button> - {% url "domains.index" 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> + {% 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> diff --git a/templates/domains/htmx/tlds.htmx.django b/templates/domains/htmx/tlds.htmx.django new file mode 100644 index 0000000..8290d41 --- /dev/null +++ b/templates/domains/htmx/tlds.htmx.django @@ -0,0 +1,66 @@ +{% url "domains.index" as help_path %} +<h1 id="page-title" class="text-sm font-medium text-zinc-100 flex items-center gap-2" hx-swap-oob="true">{{ PageTitle }}<a href="{{ help_path }}" hx-get="{{ help_path }}" hx-target="#content" hx-swap="innerHTML" hx-push-url="true" class="text-zinc-600 hover:text-zinc-400 transition-colors duration-150"><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="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 5.25h.008v.008H12v-.008Z" /></svg></a></h1> +<div class="slide-up space-y-6"> + <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">TLDs</h2> + <div class="flex items-center gap-3"> + <span class="text-xs text-zinc-600">{{ tlds|length }} total</span> + {% url "domains.tlds.new" as new_tld_path %} + <a href="{{ new_tld_path }}" hx-get="{{ new_tld_path }}" hx-target="#content" hx-swap="innerHTML" hx-push-url="true" class="btn-small">New TLD</a> + </div> + </div> + {% if tlds %} + <div class="divide-y divide-white/[0.04]"> + {% for tld in tlds %} + <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="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418" /> + </svg> + </div> + <div> + <p class="text-sm text-zinc-200">.{{ tld.Name }}</p> + {% if tld.IsDefault %} + <p class="text-xs text-zinc-600">Default</p> + {% else %} + <p class="text-xs text-zinc-600">Custom</p> + {% endif %} + </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> + {% endif %} + </div> + {% endfor %} + </div> + {% else %} + <div class="flex flex-col items-center justify-center py-16 text-center"> + <p class="text-sm text-zinc-400">No TLDs configured</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>
\ No newline at end of file diff --git a/templates/domains/index.django b/templates/domains/index.django new file mode 100644 index 0000000..2bd644f --- /dev/null +++ b/templates/domains/index.django @@ -0,0 +1,5 @@ +{% extends "layouts/dashboard.django" %} + +{% block dashboard %} +{% include "domains/htmx/index.htmx.django" %} +{% endblock %}
\ No newline at end of file diff --git a/templates/domains/tlds.django b/templates/domains/tlds.django new file mode 100644 index 0000000..efc4289 --- /dev/null +++ b/templates/domains/tlds.django @@ -0,0 +1,5 @@ +{% extends "layouts/dashboard.django" %} + +{% block dashboard %} +{% include "domains/htmx/tlds.htmx.django" %} +{% endblock %}
\ No newline at end of file diff --git a/templates/layouts/base.django b/templates/layouts/base.django index bcfab20..c858bce 100644 --- a/templates/layouts/base.django +++ b/templates/layouts/base.django @@ -15,8 +15,9 @@ </div> {% block content %}{% endblock %} <script src="/static/js/htmx.min.js"></script> - <script src="/static/js/navigation.js"></script> + <script src="/static/js/sidebar.js"></script> <script src="/static/js/dropdown.js"></script> + <script src="/static/js/confirm.js"></script> {% block scripts %}{% endblock %} </body> </html> diff --git a/templates/mail/htmx/index.htmx.django b/templates/mail/htmx/index.htmx.django new file mode 100644 index 0000000..abbd800 --- /dev/null +++ b/templates/mail/htmx/index.htmx.django @@ -0,0 +1,33 @@ +<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"> + <div class="glass rounded-xl glow-border"> + <div class="px-6 py-5 border-b border-white/[0.04]"> + <div class="flex items-center gap-3 mb-4"> + <div class="flex items-center justify-center w-10 h-10 rounded-xl bg-accent-500/10"> + <svg class="w-5 h-5 text-accent-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> + <path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75" /> + </svg> + </div> + <h2 class="text-base font-medium text-zinc-100">Mail</h2> + </div> + <p class="text-sm text-zinc-400 leading-relaxed">Dove provides a complete local mail infrastructure with full SMTP, IMAP, and POP3 support. Create users, assign mailboxes, and send and receive emails across your registered domains, all running locally with zero external dependencies.</p> + </div> + <div class="px-6 py-5 space-y-6"> + <div> + <h3 class="text-sm font-medium text-zinc-200 mb-2">Users</h3> + <p class="text-xs text-zinc-500 leading-relaxed mb-2">Mail users are the identities that own mailboxes and authenticate against the mail server. Each user has a username and display name, and can be assigned one or more mailboxes.</p> + <p class="text-xs text-zinc-500 leading-relaxed">Users authenticate via SMTP to send emails and via IMAP or POP3 to retrieve them. A single user can manage multiple mailboxes across different domains.</p> + </div> + <div class="border-t border-white/[0.04] pt-5"> + <h3 class="text-sm font-medium text-zinc-200 mb-2">Mailboxes</h3> + <p class="text-xs text-zinc-500 leading-relaxed mb-2">Mailboxes are email addresses assigned to users on your registered domains. For example, a user can own <span class="text-zinc-400">[email protected]</span> or <span class="text-zinc-400">[email protected]</span>.</p> + <p class="text-xs text-zinc-500 leading-relaxed">Each mailbox stores incoming and outgoing emails independently. Mailboxes are tied to domains managed through the Domain Manager, so you must have at least one domain registered before creating a mailbox.</p> + </div> + <div class="border-t border-white/[0.04] pt-5"> + <h3 class="text-sm font-medium text-zinc-200 mb-2">How It Works</h3> + <p class="text-xs text-zinc-500 leading-relaxed mb-2">Dove runs a full-featured SMTP server that handles both sending and receiving emails locally. Any application on your machine can connect to the SMTP server to send emails, and users can retrieve their mail through IMAP or POP3.</p> + <p class="text-xs text-zinc-500 leading-relaxed">All mail is stored locally in Dove's database. This gives you a complete email workflow for development and testing without relying on external mail services or sandbox APIs.</p> + </div> + </div> + </div> +</div>
\ No newline at end of file diff --git a/templates/mail/htmx/mailbox.htmx.django b/templates/mail/htmx/mailbox.htmx.django index 831a07d..4a10b26 100644 --- a/templates/mail/htmx/mailbox.htmx.django +++ b/templates/mail/htmx/mailbox.htmx.django @@ -1,4 +1,5 @@ -<h1 id="page-title" class="text-sm font-medium text-zinc-100" hx-swap-oob="true">{{ PageTitle }}</h1> +{% url "mail.index" as help_path %} +<h1 id="page-title" class="text-sm font-medium text-zinc-100 flex items-center gap-2" hx-swap-oob="true">{{ PageTitle }}<a href="{{ help_path }}" hx-get="{{ help_path }}" hx-target="#content" hx-swap="innerHTML" hx-push-url="true" class="text-zinc-600 hover:text-zinc-400 transition-colors duration-150"><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="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 5.25h.008v.008H12v-.008Z" /></svg></a></h1> <div class="slide-up space-y-6"> <div class="glass rounded-xl glow-border"> <div class="flex items-center justify-between px-5 py-4 border-b border-white/[0.04]"> diff --git a/templates/mail/htmx/mailboxes.htmx.django b/templates/mail/htmx/mailboxes.htmx.django index 96094f3..e2488c4 100644 --- a/templates/mail/htmx/mailboxes.htmx.django +++ b/templates/mail/htmx/mailboxes.htmx.django @@ -1,4 +1,5 @@ -<h1 id="page-title" class="text-sm font-medium text-zinc-100" hx-swap-oob="true">{{ PageTitle }}</h1> +{% url "mail.index" as help_path %} +<h1 id="page-title" class="text-sm font-medium text-zinc-100 flex items-center gap-2" hx-swap-oob="true">{{ PageTitle }}<a href="{{ help_path }}" hx-get="{{ help_path }}" hx-target="#content" hx-swap="innerHTML" hx-push-url="true" class="text-zinc-600 hover:text-zinc-400 transition-colors duration-150"><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="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 5.25h.008v.008H12v-.008Z" /></svg></a></h1> <div class="slide-up space-y-6"> <div class="glass rounded-xl glow-border"> <div class="flex items-center justify-between px-5 py-4 border-b border-white/[0.04]"> diff --git a/templates/mail/htmx/newmailbox.htmx.django b/templates/mail/htmx/newmailbox.htmx.django index 0962bea..ed886a4 100644 --- a/templates/mail/htmx/newmailbox.htmx.django +++ b/templates/mail/htmx/newmailbox.htmx.django @@ -1,4 +1,5 @@ -<h1 id="page-title" class="text-sm font-medium text-zinc-100" hx-swap-oob="true">{{ PageTitle }}</h1> +{% url "mail.index" as help_path %} +<h1 id="page-title" class="text-sm font-medium text-zinc-100 flex items-center gap-2" hx-swap-oob="true">{{ PageTitle }}<a href="{{ help_path }}" hx-get="{{ help_path }}" hx-target="#content" hx-swap="innerHTML" hx-push-url="true" class="text-zinc-600 hover:text-zinc-400 transition-colors duration-150"><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="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 5.25h.008v.008H12v-.008Z" /></svg></a></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]"> @@ -9,7 +10,34 @@ <form method="POST" action="{{ create_path }}" class="space-y-4"> <div> <label class="block text-xs font-medium text-zinc-400 mb-1.5 ml-1">Address</label> - <input type="email" name="address" required autocomplete="off" class="input-field"> + <div class="flex items-center gap-2"> + <input type="text" name="local_part" required autocomplete="off" placeholder="info" 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 a 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> <div> <label class="block text-xs font-medium text-zinc-400 mb-1.5 ml-1">Owner</label> diff --git a/templates/mail/htmx/newuser.htmx.django b/templates/mail/htmx/newuser.htmx.django index c6d4d10..ab9764d 100644 --- a/templates/mail/htmx/newuser.htmx.django +++ b/templates/mail/htmx/newuser.htmx.django @@ -1,4 +1,5 @@ -<h1 id="page-title" class="text-sm font-medium text-zinc-100" hx-swap-oob="true">{{ PageTitle }}</h1> +{% url "mail.index" as help_path %} +<h1 id="page-title" class="text-sm font-medium text-zinc-100 flex items-center gap-2" hx-swap-oob="true">{{ PageTitle }}<a href="{{ help_path }}" hx-get="{{ help_path }}" hx-target="#content" hx-swap="innerHTML" hx-push-url="true" class="text-zinc-600 hover:text-zinc-400 transition-colors duration-150"><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="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 5.25h.008v.008H12v-.008Z" /></svg></a></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]"> diff --git a/templates/mail/htmx/users.htmx.django b/templates/mail/htmx/users.htmx.django index 7ec040c..9724391 100644 --- a/templates/mail/htmx/users.htmx.django +++ b/templates/mail/htmx/users.htmx.django @@ -1,4 +1,5 @@ -<h1 id="page-title" class="text-sm font-medium text-zinc-100" hx-swap-oob="true">{{ PageTitle }}</h1> +{% url "mail.index" as help_path %} +<h1 id="page-title" class="text-sm font-medium text-zinc-100 flex items-center gap-2" hx-swap-oob="true">{{ PageTitle }}<a href="{{ help_path }}" hx-get="{{ help_path }}" hx-target="#content" hx-swap="innerHTML" hx-push-url="true" class="text-zinc-600 hover:text-zinc-400 transition-colors duration-150"><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="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 5.25h.008v.008H12v-.008Z" /></svg></a></h1> <div class="slide-up space-y-6"> <div class="glass rounded-xl glow-border"> <div class="flex items-center justify-between px-5 py-4 border-b border-white/[0.04]"> diff --git a/templates/mail/index.django b/templates/mail/index.django new file mode 100644 index 0000000..af123ab --- /dev/null +++ b/templates/mail/index.django @@ -0,0 +1,5 @@ +{% extends "layouts/dashboard.django" %} + +{% block dashboard %} +{% include "mail/htmx/index.htmx.django" %} +{% endblock %}
\ No newline at end of file diff --git a/templates/partials/sidebar.django b/templates/partials/sidebar.django index 0530bb7..adf0b28 100644 --- a/templates/partials/sidebar.django +++ b/templates/partials/sidebar.django @@ -8,35 +8,75 @@ <span class="text-sm font-semibold text-zinc-100 tracking-tight">Dove</span> </div> - <nav class="flex flex-col gap-1 p-3" id="sidebar-nav" hx-target="#content" hx-swap="innerHTML" hx-push-url="true"> + <nav class="flex flex-col gap-0.5 p-3" id="sidebar-nav" hx-target="#content" hx-swap="innerHTML" hx-push-url="true"> {% url "dashboard.index" as overview_path %} - {% url "domains.index" as domains_path %} - {% url "mail.mailboxes" as mailboxes_path %} + {% url "domains.tlds" as tlds_path %} + {% url "domains.manage" as manage_path %} {% url "mail.users" as users_path %} - <a href="{{ overview_path }}" hx-get="{{ overview_path }}" class="nav-link flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors duration-150 {% if Request.Path == overview_path %}active{% endif %}"> + {% url "mail.mailboxes" as mailboxes_path %} + + <a href="{{ overview_path }}" hx-get="{{ overview_path }}" class="nav-link flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors duration-150"> <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="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25a2.25 2.25 0 0 1-2.25-2.25v-2.25Z" /> </svg> Overview </a> - <a href="{{ domains_path }}" hx-get="{{ domains_path }}" class="nav-link flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors duration-150 {% if Request.Path == domains_path %}active{% endif %}"> - <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="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418" /> - </svg> - Domains - </a> - <a href="{{ mailboxes_path }}" hx-get="{{ mailboxes_path }}" class="nav-link flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors duration-150 {% if Request.Path == mailboxes_path %}active{% endif %}"> - <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="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> - Mailboxes - </a> - <a href="{{ users_path }}" hx-get="{{ users_path }}" class="nav-link flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors duration-150 {% if Request.Path == users_path %}active{% endif %}"> - <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="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" /> - </svg> - Users - </a> + + <div data-sidebar-section data-section-prefix="/domains"> + <button data-sidebar-trigger class="nav-link flex items-center justify-between w-full px-3 py-2 rounded-lg text-sm transition-colors duration-150"> + <span class="flex items-center gap-3"> + <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="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418" /> + </svg> + Domain Manager + </span> + <svg class="w-3.5 h-3.5 text-zinc-600 transition-transform duration-150" data-sidebar-chevron fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> + <path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" /> + </svg> + </button> + <div data-sidebar-children class="mt-0.5 flex flex-col gap-0.5 pl-3 hidden"> + <a href="{{ tlds_path }}" hx-get="{{ tlds_path }}" class="nav-link flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors duration-150"> + <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="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418" /> + </svg> + TLDs + </a> + <a href="{{ manage_path }}" hx-get="{{ manage_path }}" class="nav-link flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors duration-150"> + <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="M5.25 14.25h13.5m-13.5 0a3 3 0 0 1-3-3m3 3a3 3 0 1 0 0 6h13.5a3 3 0 1 0 0-6m-16.5-3a3 3 0 0 1 3-3h13.5a3 3 0 0 1 3 3m-19.5 0a4.5 4.5 0 0 1 .9-2.7L5.737 5.1a3.375 3.375 0 0 1 2.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 0 1 .9 2.7m0 0a3 3 0 0 1-3 3m0 3h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Zm-3 6h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Z" /> + </svg> + Domains + </a> + </div> + </div> + + <div data-sidebar-section data-section-prefix="/mail"> + <button data-sidebar-trigger class="nav-link flex items-center justify-between w-full px-3 py-2 rounded-lg text-sm transition-colors duration-150"> + <span class="flex items-center gap-3"> + <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="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75" /> + </svg> + Mail + </span> + <svg class="w-3.5 h-3.5 text-zinc-600 transition-transform duration-150" data-sidebar-chevron fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> + <path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" /> + </svg> + </button> + <div data-sidebar-children class="mt-0.5 flex flex-col gap-0.5 pl-3 hidden"> + <a href="{{ users_path }}" hx-get="{{ users_path }}" class="nav-link flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors duration-150"> + <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="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" /> + </svg> + Users + </a> + <a href="{{ mailboxes_path }}" hx-get="{{ mailboxes_path }}" class="nav-link flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors duration-150"> + <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="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> + Mailboxes + </a> + </div> + </div> </nav> <div class="mt-auto p-3 border-t border-white/[0.04]"> @@ -53,4 +93,4 @@ </a> {% endif %} </div> -</aside> +</aside>
\ No newline at end of file diff --git a/utils/validate/names.go b/utils/validate/names.go new file mode 100644 index 0000000..0d0fd25 --- /dev/null +++ b/utils/validate/names.go @@ -0,0 +1,14 @@ +package validate + +import "regexp" + +var dnsLabelPattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`) +var emailLocalPartPattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$`) + +func DNSLabel(name string) bool { + return len(name) >= 1 && len(name) <= 63 && dnsLabelPattern.MatchString(name) +} + +func EmailLocalPart(localPart string) bool { + return len(localPart) >= 1 && len(localPart) <= 64 && emailLocalPartPattern.MatchString(localPart) +} |
