feat: MVP phase 1 complete
This commit is contained in:
575
internal/web/handler.go
Normal file
575
internal/web/handler.go
Normal file
@@ -0,0 +1,575 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"arclineit/arcline-portal/internal/auth"
|
||||
"arclineit/arcline-portal/internal/db"
|
||||
"arclineit/arcline-portal/internal/mail"
|
||||
"arclineit/arcline-portal/internal/ssl"
|
||||
"arclineit/arcline-portal/internal/uptime"
|
||||
)
|
||||
|
||||
// Handler holds all HTTP handler dependencies.
|
||||
type Handler struct {
|
||||
DB *db.DB
|
||||
Uptime *uptime.Reader // may be nil if uptime DB unavailable
|
||||
Mail *mail.Mailer // may be nil if SMTP not configured
|
||||
}
|
||||
|
||||
// clientFromCtx is a package-local shortcut for auth.ClientFromContext.
|
||||
func clientFromCtx(r *http.Request) *db.Client {
|
||||
return auth.ClientFromContext(r.Context())
|
||||
}
|
||||
|
||||
func redirect(w http.ResponseWriter, r *http.Request, path string) {
|
||||
http.Redirect(w, r, path, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func redirectFlash(w http.ResponseWriter, r *http.Request, path, msg string) {
|
||||
http.Redirect(w, r, path+"?flash="+msg, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// --- Auth handlers ---
|
||||
|
||||
func (h *Handler) LoginGET(w http.ResponseWriter, r *http.Request) {
|
||||
render(w, r, "login.html", "Log in — Arcline Portal", nil)
|
||||
}
|
||||
|
||||
func (h *Handler) LoginPOST(w http.ResponseWriter, r *http.Request) {
|
||||
username := strings.TrimSpace(r.FormValue("username"))
|
||||
password := r.FormValue("password")
|
||||
|
||||
client, err := h.DB.GetClientByUsername(username)
|
||||
if err != nil || client == nil || !auth.CheckPassword(client.PasswordHash, password) {
|
||||
render(w, r, "login.html", "Log in — Arcline Portal", map[string]string{
|
||||
"Error": "Invalid username or password.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
token, err := auth.GenerateToken()
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := h.DB.CreateSession(token, client.ID, time.Now().Add(auth.SessionTTL)); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
auth.SetSessionCookie(w, token)
|
||||
redirect(w, r, "/dashboard")
|
||||
}
|
||||
|
||||
func (h *Handler) LogoutPOST(w http.ResponseWriter, r *http.Request) {
|
||||
if cookie, err := r.Cookie(auth.SessionCookie); err == nil {
|
||||
_ = h.DB.DeleteSession(cookie.Value)
|
||||
}
|
||||
auth.ClearSessionCookie(w)
|
||||
redirect(w, r, "/login")
|
||||
}
|
||||
|
||||
// --- Password reset ---
|
||||
|
||||
func (h *Handler) ForgotGET(w http.ResponseWriter, r *http.Request) {
|
||||
render(w, r, "forgot.html", "Reset Password — Arcline Portal", nil)
|
||||
}
|
||||
|
||||
func (h *Handler) ForgotPOST(w http.ResponseWriter, r *http.Request) {
|
||||
email := strings.TrimSpace(strings.ToLower(r.FormValue("email")))
|
||||
// Always show success to prevent email enumeration.
|
||||
success := map[string]string{"Success": "If that email is registered, a reset link has been sent."}
|
||||
|
||||
client, err := h.DB.GetClientByEmail(email)
|
||||
if err != nil || client == nil {
|
||||
render(w, r, "forgot.html", "Reset Password — Arcline Portal", success)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := auth.GenerateToken()
|
||||
if err != nil {
|
||||
render(w, r, "forgot.html", "Reset Password — Arcline Portal", success)
|
||||
return
|
||||
}
|
||||
if err := h.DB.CreatePasswordReset(token, client.ID); err != nil {
|
||||
slog.Error("create password reset", "err", err)
|
||||
render(w, r, "forgot.html", "Reset Password — Arcline Portal", success)
|
||||
return
|
||||
}
|
||||
if h.Mail != nil && h.Mail.Configured() {
|
||||
if err := h.Mail.SendPasswordReset(client.Email, client.DisplayName, token); err != nil {
|
||||
slog.Error("send password reset email", "err", err)
|
||||
}
|
||||
}
|
||||
render(w, r, "forgot.html", "Reset Password — Arcline Portal", success)
|
||||
}
|
||||
|
||||
func (h *Handler) ResetGET(w http.ResponseWriter, r *http.Request) {
|
||||
token := strings.TrimSpace(r.URL.Query().Get("token"))
|
||||
if token == "" {
|
||||
redirect(w, r, "/forgot")
|
||||
return
|
||||
}
|
||||
render(w, r, "reset.html", "Set New Password — Arcline Portal", map[string]string{"Token": token})
|
||||
}
|
||||
|
||||
func (h *Handler) ResetPOST(w http.ResponseWriter, r *http.Request) {
|
||||
token := strings.TrimSpace(r.FormValue("token"))
|
||||
password := r.FormValue("password")
|
||||
confirm := r.FormValue("confirm")
|
||||
|
||||
errData := func(msg string) {
|
||||
render(w, r, "reset.html", "Set New Password — Arcline Portal", map[string]string{
|
||||
"Token": token,
|
||||
"Error": msg,
|
||||
})
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
redirect(w, r, "/forgot")
|
||||
return
|
||||
}
|
||||
if len(password) < 8 {
|
||||
errData("Password must be at least 8 characters.")
|
||||
return
|
||||
}
|
||||
if password != confirm {
|
||||
errData("Passwords do not match.")
|
||||
return
|
||||
}
|
||||
|
||||
clientID, err := h.DB.UsePasswordReset(token)
|
||||
if err != nil {
|
||||
errData("Reset link is invalid or has expired.")
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := h.DB.UpdateClientPassword(clientID, hash); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
redirectFlash(w, r, "/login", "Password+updated.+Please+log+in.")
|
||||
}
|
||||
|
||||
// --- Settings ---
|
||||
|
||||
func (h *Handler) SettingsGET(w http.ResponseWriter, r *http.Request) {
|
||||
client := clientFromCtx(r)
|
||||
render(w, r, "settings.html", "Settings — Arcline Portal", map[string]string{
|
||||
"Email": client.Email,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) SettingsEmailPOST(w http.ResponseWriter, r *http.Request) {
|
||||
client := clientFromCtx(r)
|
||||
email := strings.TrimSpace(strings.ToLower(r.FormValue("email")))
|
||||
if email == "" {
|
||||
render(w, r, "settings.html", "Settings — Arcline Portal", map[string]string{
|
||||
"Email": client.Email,
|
||||
"Error": "Email cannot be empty.",
|
||||
})
|
||||
return
|
||||
}
|
||||
if err := h.DB.UpdateClientEmail(client.ID, email); err != nil {
|
||||
render(w, r, "settings.html", "Settings — Arcline Portal", map[string]string{
|
||||
"Email": client.Email,
|
||||
"Error": "Failed to update email.",
|
||||
})
|
||||
return
|
||||
}
|
||||
redirectFlash(w, r, "/settings", "Email+updated.")
|
||||
}
|
||||
|
||||
func (h *Handler) SettingsPasswordPOST(w http.ResponseWriter, r *http.Request) {
|
||||
client := clientFromCtx(r)
|
||||
current := r.FormValue("current")
|
||||
newPass := r.FormValue("password")
|
||||
confirm := r.FormValue("confirm")
|
||||
|
||||
errData := func(msg string) {
|
||||
render(w, r, "settings.html", "Settings — Arcline Portal", map[string]string{
|
||||
"Email": client.Email,
|
||||
"Error": msg,
|
||||
})
|
||||
}
|
||||
|
||||
if !auth.CheckPassword(client.PasswordHash, current) {
|
||||
errData("Current password is incorrect.")
|
||||
return
|
||||
}
|
||||
if len(newPass) < 8 {
|
||||
errData("New password must be at least 8 characters.")
|
||||
return
|
||||
}
|
||||
if newPass != confirm {
|
||||
errData("Passwords do not match.")
|
||||
return
|
||||
}
|
||||
hash, err := auth.HashPassword(newPass)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := h.DB.UpdateClientPassword(client.ID, hash); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
redirectFlash(w, r, "/settings", "Password+changed.")
|
||||
}
|
||||
|
||||
// --- Dashboard ---
|
||||
|
||||
type dashboardData struct {
|
||||
Monitors []uptime.MonitorStatus
|
||||
Domains []db.Domain
|
||||
Tickets []db.Ticket
|
||||
}
|
||||
|
||||
func (h *Handler) DashboardGET(w http.ResponseWriter, r *http.Request) {
|
||||
client := clientFromCtx(r)
|
||||
|
||||
dbMonitors, err := h.DB.ListMonitors(client.ID)
|
||||
if err != nil {
|
||||
slog.Error("list monitors", "err", err)
|
||||
}
|
||||
|
||||
var statuses []uptime.MonitorStatus
|
||||
if h.Uptime != nil && len(dbMonitors) > 0 {
|
||||
names := make([]string, len(dbMonitors))
|
||||
labels := make(map[string]string, len(dbMonitors))
|
||||
for i, m := range dbMonitors {
|
||||
names[i] = m.MonitorName
|
||||
labels[m.MonitorName] = m.Label
|
||||
}
|
||||
statuses, err = h.Uptime.GetStatus(names)
|
||||
if err != nil {
|
||||
slog.Error("get uptime status", "err", err)
|
||||
}
|
||||
for i := range statuses {
|
||||
if l, ok := labels[statuses[i].Name]; ok && l != "" {
|
||||
statuses[i].Label = l
|
||||
} else {
|
||||
statuses[i].Label = statuses[i].Name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
domains, _ := h.DB.ListDomains(client.ID)
|
||||
tickets, _ := h.DB.ListTickets(client.ID)
|
||||
|
||||
render(w, r, "dashboard.html", "Dashboard — Arcline Portal", dashboardData{
|
||||
Monitors: statuses,
|
||||
Domains: domains,
|
||||
Tickets: tickets,
|
||||
})
|
||||
}
|
||||
|
||||
// --- SSL ---
|
||||
|
||||
func (h *Handler) SSLGet(w http.ResponseWriter, r *http.Request) {
|
||||
client := clientFromCtx(r)
|
||||
domains, _ := h.DB.ListDomains(client.ID)
|
||||
render(w, r, "ssl.html", "SSL Certificates — Arcline Portal", domains)
|
||||
}
|
||||
|
||||
func (h *Handler) SSLAddPOST(w http.ResponseWriter, r *http.Request) {
|
||||
client := clientFromCtx(r)
|
||||
domain := strings.TrimSpace(strings.ToLower(r.FormValue("domain")))
|
||||
if domain == "" {
|
||||
redirectFlash(w, r, "/ssl", "Domain+cannot+be+empty.")
|
||||
return
|
||||
}
|
||||
// Strip scheme if pasted in
|
||||
domain = strings.TrimPrefix(domain, "https://")
|
||||
domain = strings.TrimPrefix(domain, "http://")
|
||||
domain = strings.TrimSuffix(domain, "/")
|
||||
|
||||
if err := h.DB.AddDomain(client.ID, domain); err != nil {
|
||||
redirectFlash(w, r, "/ssl", "Failed+to+add+domain.")
|
||||
return
|
||||
}
|
||||
// Kick off an immediate check in the background.
|
||||
go func() {
|
||||
domains, err := h.DB.ListDomains(client.ID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, d := range domains {
|
||||
if d.Domain == domain {
|
||||
res := ssl.Check(d.Domain)
|
||||
_ = h.DB.UpdateDomainStatus(d.ID, res.ExpiresAt, res.DaysRemaining, res.IsValid, res.Error)
|
||||
break
|
||||
}
|
||||
}
|
||||
}()
|
||||
redirect(w, r, "/ssl")
|
||||
}
|
||||
|
||||
func (h *Handler) SSLDeletePOST(w http.ResponseWriter, r *http.Request) {
|
||||
client := clientFromCtx(r)
|
||||
id, err := strconv.ParseInt(r.FormValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
redirect(w, r, "/ssl")
|
||||
return
|
||||
}
|
||||
// Verify the domain belongs to this client before deleting.
|
||||
domains, _ := h.DB.ListDomains(client.ID)
|
||||
for _, d := range domains {
|
||||
if d.ID == id {
|
||||
_ = h.DB.RemoveDomain(id)
|
||||
break
|
||||
}
|
||||
}
|
||||
redirect(w, r, "/ssl")
|
||||
}
|
||||
|
||||
// --- Tickets ---
|
||||
|
||||
func (h *Handler) TicketsGET(w http.ResponseWriter, r *http.Request) {
|
||||
client := clientFromCtx(r)
|
||||
tickets, _ := h.DB.ListTickets(client.ID)
|
||||
render(w, r, "tickets.html", "Support Tickets — Arcline Portal", tickets)
|
||||
}
|
||||
|
||||
func (h *Handler) TicketNewPOST(w http.ResponseWriter, r *http.Request) {
|
||||
client := clientFromCtx(r)
|
||||
subject := strings.TrimSpace(r.FormValue("subject"))
|
||||
body := strings.TrimSpace(r.FormValue("body"))
|
||||
if subject == "" || body == "" {
|
||||
redirectFlash(w, r, "/tickets", "Subject+and+message+are+required.")
|
||||
return
|
||||
}
|
||||
ticket, err := h.DB.CreateTicket(client.ID, subject, body)
|
||||
if err != nil {
|
||||
slog.Error("create ticket", "err", err)
|
||||
redirectFlash(w, r, "/tickets", "Failed+to+create+ticket.")
|
||||
return
|
||||
}
|
||||
// Notify admin of new ticket.
|
||||
if h.Mail != nil && h.Mail.Configured() {
|
||||
go func() {
|
||||
if err := h.Mail.SendTicketCreated(client.DisplayName, subject, body, ticket.ID); err != nil {
|
||||
slog.Error("send ticket created email", "err", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
redirect(w, r, fmt.Sprintf("/tickets/%d", ticket.ID))
|
||||
}
|
||||
|
||||
type ticketDetailData struct {
|
||||
Ticket *db.Ticket
|
||||
Messages []db.TicketMessage
|
||||
}
|
||||
|
||||
func (h *Handler) TicketGET(w http.ResponseWriter, r *http.Request) {
|
||||
client := clientFromCtx(r)
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
ticket, err := h.DB.GetTicket(id)
|
||||
if err != nil || ticket == nil || (!client.IsAdmin && ticket.ClientID != client.ID) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
messages, _ := h.DB.GetTicketMessages(id)
|
||||
render(w, r, "ticket.html", ticket.Subject+" — Arcline Portal", ticketDetailData{
|
||||
Ticket: ticket,
|
||||
Messages: messages,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) TicketReplyPOST(w http.ResponseWriter, r *http.Request) {
|
||||
client := clientFromCtx(r)
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
ticket, err := h.DB.GetTicket(id)
|
||||
if err != nil || ticket == nil || (!client.IsAdmin && ticket.ClientID != client.ID) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
body := strings.TrimSpace(r.FormValue("body"))
|
||||
if body == "" {
|
||||
redirect(w, r, fmt.Sprintf("/tickets/%d", id))
|
||||
return
|
||||
}
|
||||
_ = h.DB.AddTicketMessage(id, body, client.IsAdmin)
|
||||
|
||||
// Close ticket if admin checked the close box.
|
||||
if client.IsAdmin && r.FormValue("close") == "1" {
|
||||
_ = h.DB.SetTicketStatus(id, db.TicketClosed)
|
||||
}
|
||||
|
||||
// Email notifications for replies.
|
||||
if h.Mail != nil && h.Mail.Configured() {
|
||||
go func() {
|
||||
ticketOwner, err := h.DB.GetClientByID(ticket.ClientID)
|
||||
if err != nil || ticketOwner == nil {
|
||||
return
|
||||
}
|
||||
if client.IsAdmin {
|
||||
// Admin replied — notify the ticket owner.
|
||||
if ticketOwner.Email != "" {
|
||||
if err := h.Mail.SendTicketReply(
|
||||
ticketOwner.Email, ticketOwner.DisplayName,
|
||||
client.DisplayName, ticket.Subject, body, id,
|
||||
); err != nil {
|
||||
slog.Error("send ticket reply email to client", "err", err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Client replied — notify admin via SendTicketCreated re-use pattern.
|
||||
if err := h.Mail.SendTicketCreated(client.DisplayName,
|
||||
"Re: "+ticket.Subject, body, id); err != nil {
|
||||
slog.Error("send ticket reply email to admin", "err", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
redirect(w, r, fmt.Sprintf("/tickets/%d", id))
|
||||
}
|
||||
|
||||
// --- Admin ---
|
||||
|
||||
type adminIndexData struct {
|
||||
Clients []db.Client
|
||||
Tickets []db.Ticket
|
||||
}
|
||||
|
||||
func (h *Handler) AdminIndexGET(w http.ResponseWriter, r *http.Request) {
|
||||
clients, _ := h.DB.ListClients()
|
||||
tickets, _ := h.DB.ListAllTickets()
|
||||
render(w, r, "admin/index.html", "Admin — Arcline Portal", adminIndexData{
|
||||
Clients: clients,
|
||||
Tickets: tickets,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) AdminClientNewPOST(w http.ResponseWriter, r *http.Request) {
|
||||
username := strings.TrimSpace(r.FormValue("username"))
|
||||
displayName := strings.TrimSpace(r.FormValue("display_name"))
|
||||
email := strings.TrimSpace(strings.ToLower(r.FormValue("email")))
|
||||
password := r.FormValue("password")
|
||||
isAdmin := r.FormValue("is_admin") == "1"
|
||||
|
||||
if username == "" || displayName == "" || len(password) < 8 {
|
||||
redirectFlash(w, r, "/admin", "Username,+display+name+required.+Password+min+8+chars.")
|
||||
return
|
||||
}
|
||||
hash, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if _, err := h.DB.CreateClient(username, displayName, email, hash, isAdmin); err != nil {
|
||||
redirectFlash(w, r, "/admin", "Failed+to+create+client+(username+may+be+taken).")
|
||||
return
|
||||
}
|
||||
redirect(w, r, "/admin")
|
||||
}
|
||||
|
||||
type adminClientData struct {
|
||||
Client *db.Client
|
||||
Monitors []db.Monitor
|
||||
Domains []db.Domain
|
||||
}
|
||||
|
||||
func (h *Handler) AdminClientGET(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
client, err := h.DB.GetClientByID(id)
|
||||
if err != nil || client == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
monitors, _ := h.DB.ListMonitors(id)
|
||||
domains, _ := h.DB.ListDomains(id)
|
||||
render(w, r, "admin/client.html", client.DisplayName+" — Admin", adminClientData{
|
||||
Client: client,
|
||||
Monitors: monitors,
|
||||
Domains: domains,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) AdminMonitorAddPOST(w http.ResponseWriter, r *http.Request) {
|
||||
clientID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
monitorName := strings.TrimSpace(r.FormValue("monitor_name"))
|
||||
label := strings.TrimSpace(r.FormValue("label"))
|
||||
if monitorName == "" {
|
||||
redirect(w, r, fmt.Sprintf("/admin/clients/%d", clientID))
|
||||
return
|
||||
}
|
||||
_ = h.DB.AddMonitor(clientID, monitorName, label)
|
||||
redirect(w, r, fmt.Sprintf("/admin/clients/%d", clientID))
|
||||
}
|
||||
|
||||
func (h *Handler) AdminMonitorDeletePOST(w http.ResponseWriter, r *http.Request) {
|
||||
clientID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
monitorID, err := strconv.ParseInt(r.FormValue("monitor_id"), 10, 64)
|
||||
if err != nil {
|
||||
redirect(w, r, fmt.Sprintf("/admin/clients/%d", clientID))
|
||||
return
|
||||
}
|
||||
_ = h.DB.RemoveMonitor(monitorID)
|
||||
redirect(w, r, fmt.Sprintf("/admin/clients/%d", clientID))
|
||||
}
|
||||
|
||||
func (h *Handler) AdminClientDeletePOST(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_ = h.DB.DeleteClient(id)
|
||||
redirect(w, r, "/admin")
|
||||
}
|
||||
|
||||
// --- 404 ---
|
||||
|
||||
func (h *Handler) NotFoundHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
render(w, r, "404.html", "Not Found — Arcline Portal", nil)
|
||||
}
|
||||
|
||||
// RunSSLChecker runs a full pass of cert checks against all domains in the DB.
|
||||
// Call this from a background goroutine on a daily ticker.
|
||||
func (h *Handler) RunSSLChecker() {
|
||||
domains, err := h.DB.AllDomainsForCheck()
|
||||
if err != nil {
|
||||
slog.Error("ssl checker: list domains", "err", err)
|
||||
return
|
||||
}
|
||||
for _, d := range domains {
|
||||
res := ssl.Check(d.Domain)
|
||||
if err := h.DB.UpdateDomainStatus(d.ID, res.ExpiresAt, res.DaysRemaining, res.IsValid, res.Error); err != nil {
|
||||
slog.Error("ssl checker: update domain", "domain", d.Domain, "err", err)
|
||||
}
|
||||
}
|
||||
slog.Info("ssl checker: completed", "domains", len(domains))
|
||||
}
|
||||
74
internal/web/render.go
Normal file
74
internal/web/render.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed templates
|
||||
var templateFS embed.FS
|
||||
|
||||
var funcMap = template.FuncMap{
|
||||
"upper": strings.ToUpper,
|
||||
"lower": strings.ToLower,
|
||||
"formatDate": func(t time.Time) string { return t.Format("2006-01-02") },
|
||||
"formatTime": func(t time.Time) string { return t.Format("2006-01-02 15:04") },
|
||||
"ago": func(t time.Time) string {
|
||||
d := time.Since(t)
|
||||
switch {
|
||||
case d < time.Minute:
|
||||
return "just now"
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%dm ago", int(d.Minutes()))
|
||||
case d < 24*time.Hour:
|
||||
return fmt.Sprintf("%dh ago", int(d.Hours()))
|
||||
default:
|
||||
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
|
||||
}
|
||||
},
|
||||
"pct": func(f float64) string { return fmt.Sprintf("%.2f%%", f) },
|
||||
}
|
||||
|
||||
// parse returns a template set containing base.html and the named page.
|
||||
// Parsing per-request ensures each page's {{define "content"}} is isolated.
|
||||
func parse(name string) (*template.Template, error) {
|
||||
return template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/base.html", "templates/"+name)
|
||||
}
|
||||
|
||||
type pageData struct {
|
||||
Title string
|
||||
Username string
|
||||
IsAdmin bool
|
||||
Flash string
|
||||
Path string
|
||||
Data any
|
||||
}
|
||||
|
||||
func render(w http.ResponseWriter, r *http.Request, name string, title string, data any) {
|
||||
pd := pageData{
|
||||
Title: title,
|
||||
Path: r.URL.Path,
|
||||
Data: data,
|
||||
}
|
||||
// Inject client info from context if present.
|
||||
if c := clientFromCtx(r); c != nil {
|
||||
pd.Username = c.DisplayName
|
||||
pd.IsAdmin = c.IsAdmin
|
||||
}
|
||||
// Flash message from query param (redirect-after-post pattern).
|
||||
pd.Flash = r.URL.Query().Get("flash")
|
||||
|
||||
t, err := parse(name)
|
||||
if err != nil {
|
||||
http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := t.ExecuteTemplate(w, "base", pd); err != nil {
|
||||
http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
22
internal/web/templates/404.html
Normal file
22
internal/web/templates/404.html
Normal file
@@ -0,0 +1,22 @@
|
||||
{{define "content"}}
|
||||
<div class="login-wrap">
|
||||
<div class="term-window login-box">
|
||||
<div class="term-header">
|
||||
<div class="term-controls">
|
||||
<button class="term-btn term-btn--close">✕</button>
|
||||
<button class="term-btn">−</button>
|
||||
<button class="term-btn">□</button>
|
||||
</div>
|
||||
<span class="term-title">404</span>
|
||||
</div>
|
||||
<div class="term-body">
|
||||
<p class="login-prompt">$ find / -name "{{"{{"}}/* path not found */}}"</p>
|
||||
<p style="font-size:var(--font-size-2xl);font-weight:700;color:var(--text-bright);margin:.5rem 0">404</p>
|
||||
<p style="color:var(--text-dim);font-size:var(--font-size-md);margin-bottom:1.5rem">
|
||||
That page doesn't exist.
|
||||
</p>
|
||||
<a href="/dashboard" class="btn btn--ghost btn--sm">← back to dashboard</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
76
internal/web/templates/admin/client.html
Normal file
76
internal/web/templates/admin/client.html
Normal file
@@ -0,0 +1,76 @@
|
||||
{{define "content"}}
|
||||
{{with .Data}}
|
||||
<div class="page-header">
|
||||
<p class="page-header__label"><a href="/admin" class="link">admin</a> / clients</p>
|
||||
<h1 class="page-header__title">{{.Client.DisplayName}}</h1>
|
||||
<p class="text-dim td-mono">@{{.Client.Username}}</p>
|
||||
</div>
|
||||
|
||||
<section class="section">
|
||||
<div class="section__header">
|
||||
<h2 class="section__title">Service Monitors</h2>
|
||||
</div>
|
||||
<p class="muted" style="margin-bottom:1rem">
|
||||
Monitor names must match exactly what's configured in arcline-uptime.
|
||||
</p>
|
||||
|
||||
<form method="POST" action="/admin/clients/{{.Client.ID}}/monitors/add" class="inline-form">
|
||||
<input class="field__input" type="text" name="monitor_name"
|
||||
placeholder="monitor name (from arcline-uptime)" required>
|
||||
<input class="field__input" type="text" name="label"
|
||||
placeholder="display label (optional)">
|
||||
<button type="submit" class="btn btn--primary btn--sm">+ add monitor</button>
|
||||
</form>
|
||||
|
||||
{{if .Monitors}}
|
||||
<table class="table" style="margin-top:1rem">
|
||||
<thead><tr><th>monitor name</th><th>label</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Monitors}}
|
||||
<tr>
|
||||
<td class="td-mono">{{.MonitorName}}</td>
|
||||
<td class="text-dim">{{if .Label}}{{.Label}}{{else}}—{{end}}</td>
|
||||
<td>
|
||||
<form method="POST" action="/admin/clients/{{$.Data.Client.ID}}/monitors/delete" style="display:inline">
|
||||
<input type="hidden" name="monitor_id" value="{{.ID}}">
|
||||
<button type="submit" class="btn-link btn-link--danger">remove</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="muted">No monitors assigned.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2 class="section__title">Domains</h2>
|
||||
{{if .Domains}}
|
||||
<table class="table">
|
||||
<thead><tr><th>domain</th><th>expires</th><th>days</th><th>status</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Domains}}
|
||||
<tr>
|
||||
<td class="td-mono">{{.Domain}}</td>
|
||||
<td class="text-dim">{{if .IsValid}}{{formatDate .ExpiresAt}}{{else}}—{{end}}</td>
|
||||
<td class="td-mono">{{if .IsValid}}{{.DaysRemaining}}d{{else}}—{{end}}</td>
|
||||
<td>
|
||||
{{if .IsValid}}
|
||||
{{if gt .DaysRemaining 30}}<span class="badge badge--ok">OK</span>
|
||||
{{else if ge .DaysRemaining 14}}<span class="badge badge--warn">EXPIRING</span>
|
||||
{{else}}<span class="badge badge--err">CRITICAL</span>{{end}}
|
||||
{{else if .CheckError}}<span class="badge badge--err">ERROR</span>
|
||||
{{else}}<span class="badge badge--dim">PENDING</span>{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="muted">No domains tracked for this client.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
97
internal/web/templates/admin/index.html
Normal file
97
internal/web/templates/admin/index.html
Normal file
@@ -0,0 +1,97 @@
|
||||
{{define "content"}}
|
||||
<div class="page-header">
|
||||
<p class="page-header__label">admin</p>
|
||||
<h1 class="page-header__title">Admin Overview</h1>
|
||||
</div>
|
||||
|
||||
{{with .Data}}
|
||||
|
||||
<section class="section">
|
||||
<div class="section__header">
|
||||
<h2 class="section__title">Clients</h2>
|
||||
</div>
|
||||
|
||||
<div class="term-window term-window--narrow">
|
||||
<div class="term-header">
|
||||
<div class="term-controls"><button class="term-btn term-btn--close">✕</button><button class="term-btn">−</button><button class="term-btn">□</button></div>
|
||||
<span class="term-title">new-client</span>
|
||||
</div>
|
||||
<div class="term-body">
|
||||
<form method="POST" action="/admin/clients/new" class="admin-form">
|
||||
<div class="form-row">
|
||||
<div class="field">
|
||||
<label class="field__label" for="username">username</label>
|
||||
<input class="field__input" type="text" id="username" name="username" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="display_name">display name</label>
|
||||
<input class="field__input" type="text" id="display_name" name="display_name" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="email">email</label>
|
||||
<input class="field__input" type="email" id="email" name="email">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="password">password</label>
|
||||
<input class="field__input" type="password" id="password" name="password" minlength="8" required>
|
||||
</div>
|
||||
<div class="field field--check">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" name="is_admin" value="1"> admin
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn--primary btn--sm">create client</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .Clients}}
|
||||
<table class="table">
|
||||
<thead><tr><th>username</th><th>display name</th><th>role</th><th>joined</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Clients}}
|
||||
<tr>
|
||||
<td class="td-mono">{{.Username}}</td>
|
||||
<td><a href="/admin/clients/{{.ID}}" class="link">{{.DisplayName}}</a></td>
|
||||
<td>{{if .IsAdmin}}<span class="badge badge--admin">admin</span>{{else}}<span class="badge badge--dim">client</span>{{end}}</td>
|
||||
<td class="text-dim">{{formatDate .CreatedAt}}</td>
|
||||
<td>
|
||||
<form method="POST" action="/admin/clients/{{.ID}}/delete" style="display:inline">
|
||||
<button type="submit" class="btn-link btn-link--danger"
|
||||
onclick="return confirm('Delete {{.DisplayName}}? This cannot be undone.')">delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="muted">No clients yet.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2 class="section__title">All Tickets</h2>
|
||||
{{if .Tickets}}
|
||||
<table class="table">
|
||||
<thead><tr><th>#</th><th>subject</th><th>client</th><th>status</th><th>updated</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Tickets}}
|
||||
<tr>
|
||||
<td class="text-dim td-mono">#{{.ID}}</td>
|
||||
<td><a href="/tickets/{{.ID}}" class="link">{{.Subject}}</a></td>
|
||||
<td class="text-dim">{{.ClientName}}</td>
|
||||
<td><span class="badge badge--{{.Status}}">{{.Status}}</span></td>
|
||||
<td class="text-dim">{{ago .UpdatedAt}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="muted">No tickets.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
{{end}}
|
||||
{{end}}
|
||||
50
internal/web/templates/base.html
Normal file
50
internal/web/templates/base.html
Normal file
@@ -0,0 +1,50 @@
|
||||
{{define "base"}}<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.Title}}</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/css/portal.css">
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
{{if .Username}}
|
||||
<nav class="nav">
|
||||
<div class="nav__inner">
|
||||
<a href="/dashboard" class="nav__logo">
|
||||
<svg width="18" height="18" viewBox="0 0 32 32" fill="none" aria-hidden="true">
|
||||
<path d="M5 27L16 5L27 27" stroke="#00c8f0" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9 20H23" stroke="#00c8f0" stroke-width="2.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<span><span class="nav__logo-bracket">[</span>arcline<span class="nav__logo-bracket">]</span> portal</span>
|
||||
</a>
|
||||
<ul class="nav__links">
|
||||
<li><a href="/dashboard" class="nav__link{{if eq .Path "/dashboard"}} nav__link--active{{end}}">dashboard</a></li>
|
||||
<li><a href="/ssl" class="nav__link{{if eq .Path "/ssl"}} nav__link--active{{end}}">ssl</a></li>
|
||||
<li><a href="/tickets" class="nav__link{{if eq .Path "/tickets"}} nav__link--active{{end}}">tickets</a></li>
|
||||
{{if .IsAdmin}}<li><a href="/admin" class="nav__link nav__link--admin{{if eq .Path "/admin"}} nav__link--active{{end}}">admin</a></li>{{end}}
|
||||
</ul>
|
||||
<div class="nav__right">
|
||||
<a href="/settings" class="nav__link nav__link--settings{{if eq .Path "/settings"}} nav__link--active{{end}}">settings</a>
|
||||
<span class="nav__user">{{.Username}}</span>
|
||||
<form method="POST" action="/logout" style="display:inline">
|
||||
<button type="submit" class="btn btn--muted btn--sm">log out</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{{end}}
|
||||
|
||||
<main class="main">
|
||||
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
||||
{{block "content" .}}{{end}}
|
||||
</main>
|
||||
|
||||
<script src="/static/js/portal.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
94
internal/web/templates/dashboard.html
Normal file
94
internal/web/templates/dashboard.html
Normal file
@@ -0,0 +1,94 @@
|
||||
{{define "content"}}
|
||||
<div class="page-header">
|
||||
<p class="page-header__label">overview</p>
|
||||
<h1 class="page-header__title">Dashboard</h1>
|
||||
</div>
|
||||
|
||||
{{with .Data}}
|
||||
|
||||
{{/* --- Service Status --- */}}
|
||||
<section class="section">
|
||||
<div class="term-window">
|
||||
<div class="term-header">
|
||||
<div class="term-controls"><button class="term-btn term-btn--close">✕</button><button class="term-btn">−</button><button class="term-btn">□</button></div>
|
||||
<span class="term-title">service-status.sh</span>
|
||||
</div>
|
||||
<div class="term-body">
|
||||
{{if .Monitors}}
|
||||
{{range .Monitors}}
|
||||
<div class="status-row">
|
||||
<span class="status-tag {{if .Up}}status-tag--ok{{else}}status-tag--err{{end}}">
|
||||
{{if .Up}}[OK]{{else}}[!!]{{end}}
|
||||
</span>
|
||||
<span class="status-name">{{.Label}}</span>
|
||||
<span class="status-dots"></span>
|
||||
<span class="status-meta">
|
||||
{{if .Up}}up{{else}}down{{end}}
|
||||
·
|
||||
{{pct .Uptime30d}} 30d
|
||||
·
|
||||
{{if .LastChecked.IsZero}}never checked{{else}}{{ago .LastChecked}}{{end}}
|
||||
</span>
|
||||
</div>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<p class="term-empty">No services configured. Contact support to get services added to your account.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{/* --- SSL Summary --- */}}
|
||||
<section class="section">
|
||||
<div class="section__header">
|
||||
<h2 class="section__title">SSL Certificates</h2>
|
||||
<a href="/ssl" class="btn btn--ghost btn--sm">manage →</a>
|
||||
</div>
|
||||
{{if .Domains}}
|
||||
<div class="card-grid">
|
||||
{{range .Domains}}
|
||||
<div class="ssl-card {{if .IsValid}}{{if gt .DaysRemaining 30}}ssl-card--ok{{else if ge .DaysRemaining 14}}ssl-card--warn{{else}}ssl-card--crit{{end}}{{else}}ssl-card--crit{{end}}">
|
||||
<span class="ssl-domain">{{.Domain}}</span>
|
||||
{{if .IsValid}}
|
||||
<span class="ssl-days">{{.DaysRemaining}}d</span>
|
||||
<span class="ssl-exp">expires {{formatDate .ExpiresAt}}</span>
|
||||
{{else if .CheckError}}
|
||||
<span class="ssl-err">{{.CheckError}}</span>
|
||||
{{else}}
|
||||
<span class="ssl-err">not yet checked</span>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="muted"><a href="/ssl">Add a domain</a> to track SSL expiry.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
{{/* --- Recent Tickets --- */}}
|
||||
<section class="section">
|
||||
<div class="section__header">
|
||||
<h2 class="section__title">Support Tickets</h2>
|
||||
<a href="/tickets" class="btn btn--ghost btn--sm">view all →</a>
|
||||
</div>
|
||||
{{if .Tickets}}
|
||||
<table class="table">
|
||||
<thead><tr><th>#</th><th>subject</th><th>status</th><th>updated</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Tickets}}
|
||||
<tr>
|
||||
<td class="text-dim">#{{.ID}}</td>
|
||||
<td><a href="/tickets/{{.ID}}" class="link">{{.Subject}}</a></td>
|
||||
<td><span class="badge badge--{{.Status}}">{{.Status}}</span></td>
|
||||
<td class="text-dim">{{ago .UpdatedAt}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="muted">No tickets. <a href="/tickets" class="link">Open one</a> if you need help.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
{{end}}
|
||||
{{end}}
|
||||
32
internal/web/templates/forgot.html
Normal file
32
internal/web/templates/forgot.html
Normal file
@@ -0,0 +1,32 @@
|
||||
{{define "content"}}
|
||||
<div class="login-wrap">
|
||||
<div class="term-window login-box">
|
||||
<div class="term-header">
|
||||
<div class="term-controls">
|
||||
<button class="term-btn term-btn--close">✕</button>
|
||||
<button class="term-btn">−</button>
|
||||
<button class="term-btn">□</button>
|
||||
</div>
|
||||
<span class="term-title">reset-password</span>
|
||||
</div>
|
||||
<div class="term-body">
|
||||
<p class="login-prompt">Enter the email address on your account and we'll send a reset link.</p>
|
||||
|
||||
{{with .Data}}{{if .Error}}<p class="login-error">{{.Error}}</p>{{end}}
|
||||
{{if .Success}}<p class="login-success">{{.Success}}</p>{{end}}{{end}}
|
||||
|
||||
<form method="POST" action="/forgot" class="login-form">
|
||||
<div class="field">
|
||||
<label class="field__label" for="email">email address</label>
|
||||
<input class="field__input" type="email" id="email" name="email"
|
||||
autocomplete="email" autofocus required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn--primary btn--full">send reset link</button>
|
||||
</form>
|
||||
<p class="login-prompt" style="margin-top:1rem">
|
||||
<a href="/login" class="link">← back to login</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
42
internal/web/templates/login.html
Normal file
42
internal/web/templates/login.html
Normal file
@@ -0,0 +1,42 @@
|
||||
{{define "content"}}
|
||||
<div class="login-wrap">
|
||||
<div class="term-window login-box">
|
||||
<div class="term-header">
|
||||
<div class="term-controls"><button class="term-btn term-btn--close">✕</button><button class="term-btn">−</button><button class="term-btn">□</button></div>
|
||||
<span class="term-title">arcline-portal</span>
|
||||
</div>
|
||||
<div class="term-body">
|
||||
<div class="login-logo">
|
||||
<svg width="40" height="40" viewBox="0 0 32 32" fill="none" aria-hidden="true">
|
||||
<path d="M5 27L16 5L27 27" stroke="#00c8f0" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9 20H23" stroke="#00c8f0" stroke-width="2.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<span class="login-wordmark"><span class="text-dim">[</span>arcline<span class="text-dim">]</span></span>
|
||||
</div>
|
||||
|
||||
<p class="login-prompt">$ ssh client@portal.arclineit.com</p>
|
||||
|
||||
{{if .Data}}{{with .Data}}
|
||||
{{if .Error}}<p class="login-error">{{.Error}}</p>{{end}}
|
||||
{{end}}{{end}}
|
||||
|
||||
<form method="POST" action="/login" class="login-form">
|
||||
<div class="field">
|
||||
<label class="field__label" for="username">username</label>
|
||||
<input class="field__input" type="text" id="username" name="username"
|
||||
autocomplete="username" autofocus required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="password">password</label>
|
||||
<input class="field__input" type="password" id="password" name="password"
|
||||
autocomplete="current-password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn--primary btn--full">authenticate<span class="cursor">▋</span></button>
|
||||
</form>
|
||||
<p class="login-prompt" style="margin-top:1rem">
|
||||
<a href="/forgot" class="link">forgot password?</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
32
internal/web/templates/reset.html
Normal file
32
internal/web/templates/reset.html
Normal file
@@ -0,0 +1,32 @@
|
||||
{{define "content"}}
|
||||
<div class="login-wrap">
|
||||
<div class="term-window login-box">
|
||||
<div class="term-header">
|
||||
<div class="term-controls">
|
||||
<button class="term-btn term-btn--close">✕</button>
|
||||
<button class="term-btn">−</button>
|
||||
<button class="term-btn">□</button>
|
||||
</div>
|
||||
<span class="term-title">set-new-password</span>
|
||||
</div>
|
||||
<div class="term-body">
|
||||
{{with .Data}}{{if .Error}}<p class="login-error">{{.Error}}</p>{{end}}{{end}}
|
||||
|
||||
<form method="POST" action="/reset" class="login-form">
|
||||
<input type="hidden" name="token" value="{{with .Data}}{{.Token}}{{end}}">
|
||||
<div class="field">
|
||||
<label class="field__label" for="password">new password</label>
|
||||
<input class="field__input" type="password" id="password" name="password"
|
||||
autocomplete="new-password" minlength="8" autofocus required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="confirm">confirm password</label>
|
||||
<input class="field__input" type="password" id="confirm" name="confirm"
|
||||
autocomplete="new-password" minlength="8" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn--primary btn--full">set new password</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
56
internal/web/templates/settings.html
Normal file
56
internal/web/templates/settings.html
Normal file
@@ -0,0 +1,56 @@
|
||||
{{define "content"}}
|
||||
<div class="page-header">
|
||||
<p class="page-header__label">account</p>
|
||||
<h1 class="page-header__title">Settings</h1>
|
||||
</div>
|
||||
|
||||
<section class="section">
|
||||
<div class="term-window term-window--narrow">
|
||||
<div class="term-header">
|
||||
<div class="term-controls">
|
||||
<button class="term-btn term-btn--close">✕</button>
|
||||
<button class="term-btn">−</button>
|
||||
<button class="term-btn">□</button>
|
||||
</div>
|
||||
<span class="term-title">account-settings</span>
|
||||
</div>
|
||||
<div class="term-body">
|
||||
|
||||
{{with .Data}}{{if .Error}}<p class="login-error" style="margin-bottom:1rem">{{.Error}}</p>{{end}}{{end}}
|
||||
|
||||
<p class="section__title" style="margin-bottom:1rem">Email Address</p>
|
||||
<form method="POST" action="/settings/email" class="login-form" style="margin-bottom:2rem">
|
||||
<div class="field">
|
||||
<label class="field__label" for="email">email</label>
|
||||
<input class="field__input" type="email" id="email" name="email"
|
||||
value="{{with .Data}}{{.Email}}{{end}}" autocomplete="email" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn--primary btn--sm">update email</button>
|
||||
</form>
|
||||
|
||||
<hr style="border:none;border-top:1px solid var(--border);margin-bottom:1.5rem">
|
||||
|
||||
<p class="section__title" style="margin-bottom:1rem">Change Password</p>
|
||||
<form method="POST" action="/settings/password" class="login-form">
|
||||
<div class="field">
|
||||
<label class="field__label" for="current">current password</label>
|
||||
<input class="field__input" type="password" id="current" name="current"
|
||||
autocomplete="current-password" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="password">new password</label>
|
||||
<input class="field__input" type="password" id="password" name="password"
|
||||
autocomplete="new-password" minlength="8" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="confirm">confirm new password</label>
|
||||
<input class="field__input" type="password" id="confirm" name="confirm"
|
||||
autocomplete="new-password" minlength="8" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn--primary btn--sm">change password</button>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
71
internal/web/templates/ssl.html
Normal file
71
internal/web/templates/ssl.html
Normal file
@@ -0,0 +1,71 @@
|
||||
{{define "content"}}
|
||||
<div class="page-header">
|
||||
<p class="page-header__label">monitoring</p>
|
||||
<h1 class="page-header__title">SSL Certificates</h1>
|
||||
<p class="page-header__sub">Cert expiry is checked daily. Add a domain to start tracking.</p>
|
||||
</div>
|
||||
|
||||
<section class="section">
|
||||
<form method="POST" action="/ssl/add" class="inline-form">
|
||||
<input class="field__input" type="text" name="domain"
|
||||
placeholder="example.com" autocomplete="off" required>
|
||||
<button type="submit" class="btn btn--primary btn--sm">+ add domain</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
{{with .Data}}
|
||||
{{if .}}
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>domain</th>
|
||||
<th>status</th>
|
||||
<th>expires</th>
|
||||
<th>days</th>
|
||||
<th>last checked</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .}}
|
||||
<tr>
|
||||
<td class="td-mono">{{.Domain}}</td>
|
||||
<td>
|
||||
{{if .IsValid}}
|
||||
{{if gt .DaysRemaining 30}}<span class="badge badge--ok">OK</span>
|
||||
{{else if ge .DaysRemaining 14}}<span class="badge badge--warn">EXPIRING</span>
|
||||
{{else}}<span class="badge badge--err">CRITICAL</span>
|
||||
{{end}}
|
||||
{{else if .CheckError}}
|
||||
<span class="badge badge--err">ERROR</span>
|
||||
{{else}}
|
||||
<span class="badge badge--dim">PENDING</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td class="td-mono">{{if .IsValid}}{{formatDate .ExpiresAt}}{{else}}—{{end}}</td>
|
||||
<td class="td-mono">
|
||||
{{if .IsValid}}{{.DaysRemaining}}d
|
||||
{{else if .CheckError}}<span class="text-err" title="{{.CheckError}}">error</span>
|
||||
{{else}}—{{end}}
|
||||
</td>
|
||||
<td class="text-dim">
|
||||
{{if .LastCheckedAt.IsZero}}never{{else}}{{ago .LastCheckedAt}}{{end}}
|
||||
</td>
|
||||
<td>
|
||||
<form method="POST" action="/ssl/delete" style="display:inline">
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
<button type="submit" class="btn-link btn-link--danger"
|
||||
onclick="return confirm('Remove {{.Domain}}?')">remove</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="muted">No domains yet. Add one above to start tracking SSL expiry.</p>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
43
internal/web/templates/ticket.html
Normal file
43
internal/web/templates/ticket.html
Normal file
@@ -0,0 +1,43 @@
|
||||
{{define "content"}}
|
||||
{{with .Data}}
|
||||
<div class="page-header">
|
||||
<p class="page-header__label"><a href="/tickets" class="link">tickets</a> / #{{.Ticket.ID}}</p>
|
||||
<h1 class="page-header__title">{{.Ticket.Subject}}</h1>
|
||||
<span class="badge badge--{{.Ticket.Status}}">{{.Ticket.Status}}</span>
|
||||
</div>
|
||||
|
||||
<section class="section">
|
||||
<div class="thread">
|
||||
{{range .Messages}}
|
||||
<div class="message {{if .FromAdmin}}message--admin{{else}}message--client{{end}}">
|
||||
<div class="message__meta">
|
||||
<span class="message__from">{{if .FromAdmin}}arcline support{{else}}you{{end}}</span>
|
||||
<span class="message__time text-dim">{{formatTime .CreatedAt}}</span>
|
||||
</div>
|
||||
<div class="message__body">{{.Body}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if ne (print .Ticket.Status) "closed"}}
|
||||
<form method="POST" action="/tickets/{{.Ticket.ID}}/reply" class="reply-form">
|
||||
<div class="field">
|
||||
<label class="field__label" for="body">reply</label>
|
||||
<textarea class="field__textarea" id="body" name="body" rows="4"
|
||||
placeholder="Add a message..." required></textarea>
|
||||
</div>
|
||||
<div class="reply-actions">
|
||||
<button type="submit" class="btn btn--primary btn--sm">send reply</button>
|
||||
{{if $.IsAdmin}}
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" name="close" value="1"> close ticket after reply
|
||||
</label>
|
||||
{{end}}
|
||||
</div>
|
||||
</form>
|
||||
{{else}}
|
||||
<p class="muted">This ticket is closed.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
55
internal/web/templates/tickets.html
Normal file
55
internal/web/templates/tickets.html
Normal file
@@ -0,0 +1,55 @@
|
||||
{{define "content"}}
|
||||
<div class="page-header">
|
||||
<p class="page-header__label">support</p>
|
||||
<h1 class="page-header__title">Tickets</h1>
|
||||
</div>
|
||||
|
||||
<section class="section">
|
||||
<div class="term-window term-window--narrow">
|
||||
<div class="term-header">
|
||||
<div class="term-controls"><button class="term-btn term-btn--close">✕</button><button class="term-btn">−</button><button class="term-btn">□</button></div>
|
||||
<span class="term-title">new-ticket</span>
|
||||
</div>
|
||||
<div class="term-body">
|
||||
<form method="POST" action="/tickets/new" class="ticket-form">
|
||||
<div class="field">
|
||||
<label class="field__label" for="subject">subject</label>
|
||||
<input class="field__input" type="text" id="subject" name="subject"
|
||||
placeholder="Brief description of the issue" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="body">message</label>
|
||||
<textarea class="field__textarea" id="body" name="body" rows="5"
|
||||
placeholder="Describe the issue in detail..." required></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn--primary btn--sm">open ticket</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2 class="section__title">Your Tickets</h2>
|
||||
{{with .Data}}
|
||||
{{if .}}
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>#</th><th>subject</th><th>status</th><th>updated</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .}}
|
||||
<tr>
|
||||
<td class="text-dim td-mono">#{{.ID}}</td>
|
||||
<td><a href="/tickets/{{.ID}}" class="link">{{.Subject}}</a></td>
|
||||
<td><span class="badge badge--{{.Status}}">{{.Status}}</span></td>
|
||||
<td class="text-dim">{{ago .UpdatedAt}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="muted">No tickets yet.</p>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user