feat: MVP phase 1 complete

This commit is contained in:
Blake Ridgway
2026-03-25 02:41:17 -05:00
parent 81ae5c6c7b
commit bfa03e6fbf
32 changed files with 3503 additions and 39 deletions

107
internal/auth/auth.go Normal file
View File

@@ -0,0 +1,107 @@
package auth
import (
"context"
"crypto/rand"
"encoding/hex"
"net/http"
"time"
"arclineit/arcline-portal/internal/db"
"golang.org/x/crypto/bcrypt"
)
type contextKey string
const clientKey contextKey = "client"
const SessionCookie = "arc_session"
const SessionTTL = 30 * 24 * time.Hour // 30 days
// HashPassword returns a bcrypt hash of the password.
func HashPassword(password string) (string, error) {
b, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(b), err
}
// CheckPassword reports whether password matches the stored hash.
func CheckPassword(hash, password string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}
// GenerateToken returns a 32-byte hex-encoded random token.
func GenerateToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
// SetSessionCookie writes a secure session cookie to the response.
func SetSessionCookie(w http.ResponseWriter, token string) {
http.SetCookie(w, &http.Cookie{
Name: SessionCookie,
Value: token,
Path: "/",
Expires: time.Now().Add(SessionTTL),
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
}
// ClearSessionCookie removes the session cookie.
func ClearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: SessionCookie,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
}
// Middleware validates the session cookie and injects the client into context.
// Redirects to /login on missing or invalid session.
func Middleware(database *db.DB) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(SessionCookie)
if err != nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
client, err := database.GetClientBySession(cookie.Value)
if err != nil || client == nil {
ClearSessionCookie(w)
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
ctx := context.WithValue(r.Context(), clientKey, client)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// AdminMiddleware enforces that the authenticated client is an admin.
// Must be used after Middleware.
func AdminMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
client := ClientFromContext(r.Context())
if client == nil || !client.IsAdmin {
http.Error(w, "403 Forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
// ClientFromContext retrieves the authenticated client from context.
// Returns nil if not present.
func ClientFromContext(ctx context.Context) *db.Client {
c, _ := ctx.Value(clientKey).(*db.Client)
return c
}

605
internal/db/db.go Normal file
View File

@@ -0,0 +1,605 @@
package db
import (
"database/sql"
"fmt"
"time"
_ "modernc.org/sqlite"
)
// DB wraps the portal SQLite database.
type DB struct {
db *sql.DB
}
// --- Model types ---
type Client struct {
ID int64
Username string
DisplayName string
Email string
PasswordHash string
IsAdmin bool
CreatedAt time.Time
}
type Session struct {
Token string
ClientID int64
ExpiresAt time.Time
}
// Domain is a customer-owned domain tracked for SSL expiry.
type Domain struct {
ID int64
ClientID int64
Domain string
AddedAt time.Time
LastCheckedAt time.Time
ExpiresAt time.Time
DaysRemaining int
IsValid bool
CheckError string
}
// Monitor links a client to a monitor name in arcline-uptime.
type Monitor struct {
ID int64
ClientID int64
MonitorName string
Label string // human-friendly display name
}
type TicketStatus string
const (
TicketOpen TicketStatus = "open"
TicketInProgress TicketStatus = "in_progress"
TicketClosed TicketStatus = "closed"
)
type Ticket struct {
ID int64
ClientID int64
ClientName string // populated by ListAllTickets (admin view)
Subject string
Status TicketStatus
CreatedAt time.Time
UpdatedAt time.Time
}
type TicketMessage struct {
ID int64
TicketID int64
Body string
FromAdmin bool
CreatedAt time.Time
}
type PasswordReset struct {
Token string
ClientID int64
ExpiresAt time.Time
Used bool
}
// --- Open / migrate ---
func Open(path string) (*DB, error) {
sqlDB, err := sql.Open("sqlite", path+"?_foreign_keys=on")
if err != nil {
return nil, fmt.Errorf("open db: %w", err)
}
sqlDB.SetMaxOpenConns(1)
d := &DB{db: sqlDB}
if err := d.migrate(); err != nil {
sqlDB.Close()
return nil, fmt.Errorf("migrate: %w", err)
}
return d, nil
}
func (d *DB) Close() error { return d.db.Close() }
func (d *DB) migrate() error {
_, err := d.db.Exec(`
CREATE TABLE IF NOT EXISTS clients (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
email TEXT NOT NULL DEFAULT '',
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE INDEX IF NOT EXISTS idx_sessions_client ON sessions(client_id);
CREATE TABLE IF NOT EXISTS password_resets (
token TEXT PRIMARY KEY,
client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
expires_at INTEGER NOT NULL,
used INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS client_monitors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
monitor_name TEXT NOT NULL,
label TEXT NOT NULL DEFAULT '',
UNIQUE(client_id, monitor_name)
);
CREATE TABLE IF NOT EXISTS domains (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
domain TEXT NOT NULL,
added_at INTEGER NOT NULL DEFAULT (unixepoch()),
last_checked_at INTEGER NOT NULL DEFAULT 0,
expires_at INTEGER NOT NULL DEFAULT 0,
days_remaining INTEGER NOT NULL DEFAULT 0,
is_valid INTEGER NOT NULL DEFAULT 0,
check_error TEXT NOT NULL DEFAULT '',
UNIQUE(client_id, domain)
);
CREATE TABLE IF NOT EXISTS tickets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
subject TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'open',
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE INDEX IF NOT EXISTS idx_tickets_client ON tickets(client_id, updated_at DESC);
CREATE TABLE IF NOT EXISTS ticket_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticket_id INTEGER NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
body TEXT NOT NULL,
from_admin INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE INDEX IF NOT EXISTS idx_messages_ticket ON ticket_messages(ticket_id, created_at ASC);
`)
if err != nil {
return err
}
// Add email column to existing databases — SQLite has no IF NOT EXISTS for columns.
_, _ = d.db.Exec(`ALTER TABLE clients ADD COLUMN email TEXT NOT NULL DEFAULT ''`)
return nil
}
// --- Client queries ---
func (d *DB) CreateClient(username, displayName, email, passwordHash string, isAdmin bool) (*Client, error) {
res, err := d.db.Exec(
`INSERT INTO clients (username, display_name, email, password_hash, is_admin) VALUES (?, ?, ?, ?, ?)`,
username, displayName, email, passwordHash, boolToInt(isAdmin),
)
if err != nil {
return nil, err
}
id, _ := res.LastInsertId()
return d.GetClientByID(id)
}
func (d *DB) GetClientByID(id int64) (*Client, error) {
row := d.db.QueryRow(
`SELECT id, username, display_name, email, password_hash, is_admin, created_at FROM clients WHERE id = ?`, id,
)
return scanClient(row)
}
func (d *DB) GetClientByUsername(username string) (*Client, error) {
row := d.db.QueryRow(
`SELECT id, username, display_name, email, password_hash, is_admin, created_at FROM clients WHERE username = ?`, username,
)
return scanClient(row)
}
func (d *DB) GetClientByEmail(email string) (*Client, error) {
row := d.db.QueryRow(
`SELECT id, username, display_name, email, password_hash, is_admin, created_at FROM clients WHERE email = ? AND email != ''`, email,
)
return scanClient(row)
}
func (d *DB) UpdateClientEmail(clientID int64, email string) error {
_, err := d.db.Exec(`UPDATE clients SET email = ? WHERE id = ?`, email, clientID)
return err
}
func (d *DB) ListClients() ([]Client, error) {
rows, err := d.db.Query(
`SELECT id, username, display_name, email, password_hash, is_admin, created_at FROM clients ORDER BY display_name`,
)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Client
for rows.Next() {
c, err := scanClientFromRows(rows)
if err != nil {
return nil, err
}
out = append(out, *c)
}
return out, rows.Err()
}
func (d *DB) UpdateClientPassword(clientID int64, hash string) error {
_, err := d.db.Exec(`UPDATE clients SET password_hash = ? WHERE id = ?`, hash, clientID)
return err
}
func (d *DB) DeleteClient(id int64) error {
_, err := d.db.Exec(`DELETE FROM clients WHERE id = ?`, id)
return err
}
// --- Session queries ---
func (d *DB) CreateSession(token string, clientID int64, expiresAt time.Time) error {
_, err := d.db.Exec(
`INSERT INTO sessions (token, client_id, expires_at) VALUES (?, ?, ?)`,
token, clientID, expiresAt.Unix(),
)
return err
}
func (d *DB) GetClientBySession(token string) (*Client, error) {
var clientID int64
var exp int64
err := d.db.QueryRow(
`SELECT client_id, expires_at FROM sessions WHERE token = ?`, token,
).Scan(&clientID, &exp)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("session not found")
}
if err != nil {
return nil, err
}
if time.Now().After(time.Unix(exp, 0)) {
_ = d.DeleteSession(token)
return nil, fmt.Errorf("session expired")
}
return d.GetClientByID(clientID)
}
func (d *DB) DeleteSession(token string) error {
_, err := d.db.Exec(`DELETE FROM sessions WHERE token = ?`, token)
return err
}
func (d *DB) PruneSessions() error {
_, err := d.db.Exec(`DELETE FROM sessions WHERE expires_at < ?`, time.Now().Unix())
return err
}
// --- Password reset queries ---
func (d *DB) CreatePasswordReset(token string, clientID int64) error {
exp := time.Now().Add(time.Hour).Unix()
_, err := d.db.Exec(
`INSERT INTO password_resets (token, client_id, expires_at) VALUES (?, ?, ?)`,
token, clientID, exp,
)
return err
}
func (d *DB) UsePasswordReset(token string) (int64, error) {
var clientID int64
var exp int64
var used int
err := d.db.QueryRow(
`SELECT client_id, expires_at, used FROM password_resets WHERE token = ?`, token,
).Scan(&clientID, &exp, &used)
if err == sql.ErrNoRows {
return 0, fmt.Errorf("reset token not found")
}
if err != nil {
return 0, err
}
if used != 0 {
return 0, fmt.Errorf("reset token already used")
}
if time.Now().After(time.Unix(exp, 0)) {
return 0, fmt.Errorf("reset token expired")
}
_, err = d.db.Exec(`UPDATE password_resets SET used = 1 WHERE token = ?`, token)
return clientID, err
}
// --- Monitor queries ---
func (d *DB) AddMonitor(clientID int64, monitorName, label string) error {
_, err := d.db.Exec(
`INSERT OR IGNORE INTO client_monitors (client_id, monitor_name, label) VALUES (?, ?, ?)`,
clientID, monitorName, label,
)
return err
}
func (d *DB) RemoveMonitor(id int64) error {
_, err := d.db.Exec(`DELETE FROM client_monitors WHERE id = ?`, id)
return err
}
func (d *DB) ListMonitors(clientID int64) ([]Monitor, error) {
rows, err := d.db.Query(
`SELECT id, client_id, monitor_name, label FROM client_monitors WHERE client_id = ? ORDER BY label, monitor_name`,
clientID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Monitor
for rows.Next() {
var m Monitor
if err := rows.Scan(&m.ID, &m.ClientID, &m.MonitorName, &m.Label); err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}
// --- Domain queries ---
func (d *DB) AddDomain(clientID int64, domain string) error {
_, err := d.db.Exec(
`INSERT OR IGNORE INTO domains (client_id, domain) VALUES (?, ?)`,
clientID, domain,
)
return err
}
func (d *DB) RemoveDomain(id int64) error {
_, err := d.db.Exec(`DELETE FROM domains WHERE id = ?`, id)
return err
}
func (d *DB) UpdateDomainStatus(id int64, expiresAt time.Time, daysRemaining int, isValid bool, checkErr string) error {
_, err := d.db.Exec(
`UPDATE domains SET last_checked_at = ?, expires_at = ?, days_remaining = ?, is_valid = ?, check_error = ? WHERE id = ?`,
time.Now().Unix(), expiresAt.Unix(), daysRemaining, boolToInt(isValid), checkErr, id,
)
return err
}
func (d *DB) ListDomains(clientID int64) ([]Domain, error) {
rows, err := d.db.Query(
`SELECT id, client_id, domain, added_at, last_checked_at, expires_at, days_remaining, is_valid, check_error
FROM domains WHERE client_id = ? ORDER BY domain`,
clientID,
)
if err != nil {
return nil, err
}
defer rows.Close()
return scanDomains(rows)
}
// AllDomainsForCheck returns all domains across all clients (for the background checker).
func (d *DB) AllDomainsForCheck() ([]Domain, error) {
rows, err := d.db.Query(
`SELECT id, client_id, domain, added_at, last_checked_at, expires_at, days_remaining, is_valid, check_error FROM domains`,
)
if err != nil {
return nil, err
}
defer rows.Close()
return scanDomains(rows)
}
// --- Ticket queries ---
func (d *DB) CreateTicket(clientID int64, subject, body string) (*Ticket, error) {
res, err := d.db.Exec(
`INSERT INTO tickets (client_id, subject) VALUES (?, ?)`, clientID, subject,
)
if err != nil {
return nil, err
}
id, _ := res.LastInsertId()
_, err = d.db.Exec(
`INSERT INTO ticket_messages (ticket_id, body, from_admin) VALUES (?, ?, 0)`, id, body,
)
if err != nil {
return nil, err
}
return d.GetTicket(id)
}
func (d *DB) GetTicket(id int64) (*Ticket, error) {
row := d.db.QueryRow(
`SELECT id, client_id, subject, status, created_at, updated_at FROM tickets WHERE id = ?`, id,
)
return scanTicket(row)
}
func (d *DB) ListTickets(clientID int64) ([]Ticket, error) {
rows, err := d.db.Query(
`SELECT id, client_id, subject, status, created_at, updated_at
FROM tickets WHERE client_id = ? ORDER BY updated_at DESC`,
clientID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Ticket
for rows.Next() {
t, err := scanTicketFromRows(rows)
if err != nil {
return nil, err
}
out = append(out, *t)
}
return out, rows.Err()
}
func (d *DB) ListAllTickets() ([]Ticket, error) {
rows, err := d.db.Query(
`SELECT t.id, t.client_id, c.display_name, t.subject, t.status, t.created_at, t.updated_at
FROM tickets t
JOIN clients c ON c.id = t.client_id
ORDER BY t.updated_at DESC`,
)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Ticket
for rows.Next() {
var t Ticket
var createdTS, updatedTS int64
if err := rows.Scan(&t.ID, &t.ClientID, &t.ClientName, &t.Subject, &t.Status, &createdTS, &updatedTS); err != nil {
return nil, err
}
t.CreatedAt = time.Unix(createdTS, 0)
t.UpdatedAt = time.Unix(updatedTS, 0)
out = append(out, t)
}
return out, rows.Err()
}
func (d *DB) AddTicketMessage(ticketID int64, body string, fromAdmin bool) error {
_, err := d.db.Exec(
`INSERT INTO ticket_messages (ticket_id, body, from_admin) VALUES (?, ?, ?)`,
ticketID, body, boolToInt(fromAdmin),
)
if err != nil {
return err
}
_, err = d.db.Exec(
`UPDATE tickets SET updated_at = unixepoch() WHERE id = ?`, ticketID,
)
return err
}
func (d *DB) SetTicketStatus(id int64, status TicketStatus) error {
_, err := d.db.Exec(`UPDATE tickets SET status = ?, updated_at = unixepoch() WHERE id = ?`, string(status), id)
return err
}
func (d *DB) GetTicketMessages(ticketID int64) ([]TicketMessage, error) {
rows, err := d.db.Query(
`SELECT id, ticket_id, body, from_admin, created_at FROM ticket_messages WHERE ticket_id = ? ORDER BY created_at ASC`,
ticketID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var out []TicketMessage
for rows.Next() {
var m TicketMessage
var ts int64
var fa int
if err := rows.Scan(&m.ID, &m.TicketID, &m.Body, &fa, &ts); err != nil {
return nil, err
}
m.FromAdmin = fa != 0
m.CreatedAt = time.Unix(ts, 0)
out = append(out, m)
}
return out, rows.Err()
}
// --- Helpers ---
func scanClient(row *sql.Row) (*Client, error) {
var c Client
var ts int64
var admin int
err := row.Scan(&c.ID, &c.Username, &c.DisplayName, &c.Email, &c.PasswordHash, &admin, &ts)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
c.IsAdmin = admin != 0
c.CreatedAt = time.Unix(ts, 0)
return &c, nil
}
func scanClientFromRows(rows *sql.Rows) (*Client, error) {
var c Client
var ts int64
var admin int
if err := rows.Scan(&c.ID, &c.Username, &c.DisplayName, &c.Email, &c.PasswordHash, &admin, &ts); err != nil {
return nil, err
}
c.IsAdmin = admin != 0
c.CreatedAt = time.Unix(ts, 0)
return &c, nil
}
func scanDomains(rows *sql.Rows) ([]Domain, error) {
var out []Domain
for rows.Next() {
var dom Domain
var addedTS, checkedTS, expiresTS int64
var valid int
if err := rows.Scan(
&dom.ID, &dom.ClientID, &dom.Domain,
&addedTS, &checkedTS, &expiresTS,
&dom.DaysRemaining, &valid, &dom.CheckError,
); err != nil {
return nil, err
}
dom.AddedAt = time.Unix(addedTS, 0)
dom.LastCheckedAt = time.Unix(checkedTS, 0)
dom.ExpiresAt = time.Unix(expiresTS, 0)
dom.IsValid = valid != 0
out = append(out, dom)
}
return out, rows.Err()
}
func scanTicket(row *sql.Row) (*Ticket, error) {
var t Ticket
var createdTS, updatedTS int64
err := row.Scan(&t.ID, &t.ClientID, &t.Subject, &t.Status, &createdTS, &updatedTS)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
t.CreatedAt = time.Unix(createdTS, 0)
t.UpdatedAt = time.Unix(updatedTS, 0)
return &t, nil
}
func scanTicketFromRows(rows *sql.Rows) (*Ticket, error) {
var t Ticket
var createdTS, updatedTS int64
if err := rows.Scan(&t.ID, &t.ClientID, &t.Subject, &t.Status, &createdTS, &updatedTS); err != nil {
return nil, err
}
t.CreatedAt = time.Unix(createdTS, 0)
t.UpdatedAt = time.Unix(updatedTS, 0)
return &t, nil
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}

124
internal/mail/mail.go Normal file
View File

@@ -0,0 +1,124 @@
// Package mail sends transactional emails via SMTP with STARTTLS.
package mail
import (
"bytes"
"fmt"
"net"
"net/smtp"
"strings"
"time"
)
// Config holds SMTP connection settings sourced from environment variables.
type Config struct {
Host string // SMTP_HOST
Port string // SMTP_PORT (default "587")
Username string // SMTP_USER
Password string // SMTP_PASS
From string // SMTP_FROM
AdminEmail string // ADMIN_EMAIL
BaseURL string // BASE_URL (used in link generation)
}
// Mailer sends email using the provided Config.
type Mailer struct {
cfg Config
}
// New returns a Mailer. Returns an error if required fields are missing.
func New(cfg Config) (*Mailer, error) {
if cfg.Host == "" || cfg.From == "" {
return nil, fmt.Errorf("mail: SMTP_HOST and SMTP_FROM are required")
}
if cfg.Port == "" {
cfg.Port = "587"
}
return &Mailer{cfg: cfg}, nil
}
// Configured reports whether the mailer has enough config to send.
func (m *Mailer) Configured() bool {
return m.cfg.Host != "" && m.cfg.From != ""
}
// SendPasswordReset sends a password-reset link to the given address.
func (m *Mailer) SendPasswordReset(toAddr, toName, token string) error {
link := strings.TrimRight(m.cfg.BaseURL, "/") + "/reset?token=" + token
subject := "Reset your Arcline Portal password"
body := fmt.Sprintf(`Hi %s,
Someone requested a password reset for your Arcline Portal account.
If that was you, click the link below to set a new password.
The link expires in 1 hour.
%s
If you did not request this, you can safely ignore this email.
— Arcline IT
`, toName, link)
return m.send(toAddr, subject, body)
}
// SendTicketCreated notifies the admin that a new ticket was opened.
func (m *Mailer) SendTicketCreated(clientName, subject, body string, ticketID int64) error {
if m.cfg.AdminEmail == "" {
return nil
}
link := strings.TrimRight(m.cfg.BaseURL, "/") + fmt.Sprintf("/tickets/%d", ticketID)
msg := fmt.Sprintf(`New support ticket from %s
Subject: %s
%s
---
View ticket: %s
`, clientName, subject, body, link)
return m.send(m.cfg.AdminEmail, fmt.Sprintf("[Arcline Portal] New ticket: %s", subject), msg)
}
// SendTicketReply notifies a party that a reply was added to their ticket.
// toAddr is the recipient; fromName is who replied.
func (m *Mailer) SendTicketReply(toAddr, toName, fromName, ticketSubject, replyBody string, ticketID int64) error {
if toAddr == "" {
return nil
}
link := strings.TrimRight(m.cfg.BaseURL, "/") + fmt.Sprintf("/tickets/%d", ticketID)
msg := fmt.Sprintf(`Hi %s,
%s replied to your ticket "%s":
---
%s
---
View the full thread: %s
— Arcline IT
`, toName, fromName, ticketSubject, replyBody, link)
return m.send(toAddr, fmt.Sprintf("[Arcline Portal] Re: %s", ticketSubject), msg)
}
// send composes and sends a plain-text email via SMTP STARTTLS.
func (m *Mailer) send(to, subject, body string) error {
addr := net.JoinHostPort(m.cfg.Host, m.cfg.Port)
var buf bytes.Buffer
fmt.Fprintf(&buf, "From: %s\r\n", m.cfg.From)
fmt.Fprintf(&buf, "To: %s\r\n", to)
fmt.Fprintf(&buf, "Subject: %s\r\n", subject)
fmt.Fprintf(&buf, "Date: %s\r\n", time.Now().Format("Mon, 02 Jan 2006 15:04:05 -0700"))
fmt.Fprintf(&buf, "MIME-Version: 1.0\r\n")
fmt.Fprintf(&buf, "Content-Type: text/plain; charset=UTF-8\r\n")
fmt.Fprintf(&buf, "\r\n")
fmt.Fprintf(&buf, "%s", strings.ReplaceAll(body, "\n", "\r\n"))
var auth smtp.Auth
if m.cfg.Username != "" {
auth = smtp.PlainAuth("", m.cfg.Username, m.cfg.Password, m.cfg.Host)
}
return smtp.SendMail(addr, auth, m.cfg.From, []string{to}, buf.Bytes())
}

78
internal/ssl/checker.go Normal file
View File

@@ -0,0 +1,78 @@
package ssl
import (
"crypto/tls"
"fmt"
"net"
"time"
)
// Result holds the outcome of a single cert check.
type Result struct {
Domain string
ExpiresAt time.Time
DaysRemaining int
IsValid bool
Error string
}
// Severity returns a CSS class name for the expiry status.
//
// "ok" — > 30 days
// "warn" — 1430 days
// "crit" — < 14 days or invalid
func (r Result) Severity() string {
if !r.IsValid {
return "crit"
}
switch {
case r.DaysRemaining > 30:
return "ok"
case r.DaysRemaining >= 14:
return "warn"
default:
return "crit"
}
}
// Check dials domain:443, retrieves the TLS certificate chain, and returns
// the expiry of the leaf certificate.
func Check(domain string) Result {
r := Result{Domain: domain}
conn, err := tls.DialWithDialer(
&net.Dialer{Timeout: 10 * time.Second},
"tcp",
net.JoinHostPort(domain, "443"),
&tls.Config{ServerName: domain},
)
if err != nil {
r.Error = fmt.Sprintf("tls dial: %s", err)
return r
}
defer conn.Close()
certs := conn.ConnectionState().PeerCertificates
if len(certs) == 0 {
r.Error = "no certificates in chain"
return r
}
leaf := certs[0]
now := time.Now()
r.ExpiresAt = leaf.NotAfter
r.DaysRemaining = int(leaf.NotAfter.Sub(now).Hours() / 24)
if now.Before(leaf.NotBefore) {
r.Error = fmt.Sprintf("certificate not yet valid (valid from %s)", leaf.NotBefore.Format("2006-01-02"))
return r
}
if now.After(leaf.NotAfter) {
r.Error = fmt.Sprintf("certificate expired %s", leaf.NotAfter.Format("2006-01-02"))
return r
}
r.IsValid = true
return r
}

107
internal/uptime/reader.go Normal file
View File

@@ -0,0 +1,107 @@
// Package uptime provides read-only access to arcline-uptime's SQLite database.
// The portal never writes to the uptime DB — it only queries it.
package uptime
import (
"database/sql"
"fmt"
"time"
_ "modernc.org/sqlite"
)
// Reader is a read-only view of the arcline-uptime database.
type Reader struct {
db *sql.DB
}
// MonitorStatus is a summary of a single monitor's current state.
type MonitorStatus struct {
Name string
Label string // display label from the portal (not from uptime)
Up bool
LastChecked time.Time
ResponseMS int64
Uptime24h float64
Uptime7d float64
Uptime30d float64
}
// Open opens the uptime database in read-only mode.
func Open(path string) (*Reader, error) {
db, err := sql.Open("sqlite", fmt.Sprintf("file:%s?mode=ro", path))
if err != nil {
return nil, fmt.Errorf("open uptime db: %w", err)
}
db.SetMaxOpenConns(1)
// Verify the expected schema exists.
var n int
if err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='checks'`).Scan(&n); err != nil || n == 0 {
db.Close()
return nil, fmt.Errorf("uptime db does not contain a 'checks' table — is the path correct?")
}
return &Reader{db: db}, nil
}
func (r *Reader) Close() error { return r.db.Close() }
// GetStatus returns the current status for each of the supplied monitor names.
// Unknown monitors (no check records) are included with Up=false.
func (r *Reader) GetStatus(monitors []string) ([]MonitorStatus, error) {
out := make([]MonitorStatus, 0, len(monitors))
for _, name := range monitors {
ms := MonitorStatus{Name: name}
// Latest check
var ts int64
var up, statusCode int
var responseMS int64
err := r.db.QueryRow(
`SELECT checked_at, up, status_code, response_ms FROM checks
WHERE monitor_name = ? ORDER BY checked_at DESC LIMIT 1`,
name,
).Scan(&ts, &up, &statusCode, &responseMS)
if err != nil && err != sql.ErrNoRows {
return nil, err
}
if err != sql.ErrNoRows {
ms.Up = up != 0
ms.LastChecked = time.Unix(ts, 0)
ms.ResponseMS = responseMS
}
// Uptime percentages
ms.Uptime24h, _ = r.uptimePct(name, time.Now().Add(-24*time.Hour))
ms.Uptime7d, _ = r.uptimePct(name, time.Now().Add(-7*24*time.Hour))
ms.Uptime30d, _ = r.uptimePct(name, time.Now().Add(-30*24*time.Hour))
out = append(out, ms)
}
return out, nil
}
func (r *Reader) uptimePct(monitorName string, since time.Time) (float64, error) {
var total, upCount int64
err := r.db.QueryRow(
`SELECT COUNT(*), COALESCE(SUM(up), 0) FROM checks WHERE monitor_name = ? AND checked_at >= ?`,
monitorName, since.Unix(),
).Scan(&total, &upCount)
if err != nil {
return 0, err
}
if total == 0 {
return 100.0, nil
}
return float64(upCount) / float64(total) * 100.0, nil
}
// Available reports whether the uptime DB can be opened at path.
// Used at startup to warn if the path is wrong without hard-failing.
func Available(path string) bool {
r, err := Open(path)
if err != nil {
return false
}
r.Close()
return true
}

575
internal/web/handler.go Normal file
View 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
View 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)
}
}

View 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}}

View 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}}&mdash;{{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}}&mdash;{{end}}</td>
<td class="td-mono">{{if .IsValid}}{{.DaysRemaining}}d{{else}}&mdash;{{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}}

View 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}}

View 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}}

View 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}}
&nbsp;·&nbsp;
{{pct .Uptime30d}} 30d
&nbsp;·&nbsp;
{{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}}

View 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}}

View 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}}

View 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}}

View 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}}

View 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}}&mdash;{{end}}</td>
<td class="td-mono">
{{if .IsValid}}{{.DaysRemaining}}d
{{else if .CheckError}}<span class="text-err" title="{{.CheckError}}">error</span>
{{else}}&mdash;{{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}}

View 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}}

View 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}}