first commit

This commit is contained in:
Blake Ridgway
2026-03-27 23:18:17 -05:00
commit d8cb1d277f
19 changed files with 2557 additions and 0 deletions

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

@@ -0,0 +1,194 @@
package auth
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"net/http"
"time"
"golang.org/x/crypto/bcrypt"
)
const (
bcryptCost = 12
SessionTTL = 30 * 24 * time.Hour
resetTokenTTL = 1 * time.Hour
)
type contextKey int
const contextKeyCustomerID contextKey = iota
// HashPassword hashes plain with bcrypt cost 12.
func HashPassword(plain string) (string, error) {
b, err := bcrypt.GenerateFromPassword([]byte(plain), bcryptCost)
if err != nil {
return "", fmt.Errorf("hash password: %w", err)
}
return string(b), nil
}
// CheckPassword returns true when plain matches the stored bcrypt hash.
func CheckPassword(hash, plain string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) == nil
}
// CreateSession inserts a new session row and returns the random token.
func CreateSession(db *sql.DB, customerID int64) (string, error) {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", fmt.Errorf("rand token: %w", err)
}
token := hex.EncodeToString(raw)
expiresAt := time.Now().UTC().Add(SessionTTL).Format(time.RFC3339)
_, err := db.Exec(
`INSERT INTO sessions (token, customer_id, expires_at) VALUES (?, ?, ?)`,
token, customerID, expiresAt,
)
if err != nil {
return "", fmt.Errorf("insert session: %w", err)
}
return token, nil
}
// GetSession looks up a session token and returns the associated customer ID.
func GetSession(db *sql.DB, token string) (int64, error) {
var customerID int64
var expiresAt string
err := db.QueryRow(
`SELECT customer_id, expires_at FROM sessions WHERE token = ?`,
token,
).Scan(&customerID, &expiresAt)
if errors.Is(err, sql.ErrNoRows) {
return 0, fmt.Errorf("session not found")
}
if err != nil {
return 0, fmt.Errorf("query session: %w", err)
}
exp, err := time.Parse(time.RFC3339, expiresAt)
if err != nil {
return 0, fmt.Errorf("parse expires_at: %w", err)
}
if time.Now().UTC().After(exp) {
_, _ = db.Exec(`DELETE FROM sessions WHERE token = ?`, token)
return 0, fmt.Errorf("session expired")
}
return customerID, nil
}
// DeleteSession removes a session from the database.
func DeleteSession(db *sql.DB, token string) error {
_, err := db.Exec(`DELETE FROM sessions WHERE token = ?`, token)
if err != nil {
return fmt.Errorf("delete session: %w", err)
}
return nil
}
// CreateResetToken inserts a new password_resets row and returns the token.
func CreateResetToken(db *sql.DB, customerID int64) (string, error) {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", fmt.Errorf("rand token: %w", err)
}
token := hex.EncodeToString(raw)
expiresAt := time.Now().UTC().Add(resetTokenTTL).Format(time.RFC3339)
_, err := db.Exec(
`INSERT INTO password_resets (token, customer_id, expires_at) VALUES (?, ?, ?)`,
token, customerID, expiresAt,
)
if err != nil {
return "", fmt.Errorf("insert reset token: %w", err)
}
return token, nil
}
// ValidateResetToken checks the token is valid and unused, returns customer ID.
func ValidateResetToken(db *sql.DB, token string) (int64, error) {
var customerID int64
var expiresAt string
var used int
err := db.QueryRow(
`SELECT customer_id, expires_at, used FROM password_resets WHERE token = ?`,
token,
).Scan(&customerID, &expiresAt, &used)
if errors.Is(err, sql.ErrNoRows) {
return 0, fmt.Errorf("reset token not found")
}
if err != nil {
return 0, fmt.Errorf("query reset token: %w", err)
}
if used != 0 {
return 0, fmt.Errorf("reset token already used")
}
exp, err := time.Parse(time.RFC3339, expiresAt)
if err != nil {
return 0, fmt.Errorf("parse expires_at: %w", err)
}
if time.Now().UTC().After(exp) {
return 0, fmt.Errorf("reset token expired")
}
return customerID, nil
}
// ConsumeResetToken marks the token as used.
func ConsumeResetToken(db *sql.DB, token string) error {
_, err := db.Exec(`UPDATE password_resets SET used = 1 WHERE token = ?`, token)
if err != nil {
return fmt.Errorf("consume reset token: %w", err)
}
return nil
}
// Middleware returns HTTP middleware that validates the session cookie and
// injects the customer ID into the request context.
func Middleware(db *sql.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("session")
if err != nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
customerID, err := GetSession(db, cookie.Value)
if err != nil {
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
})
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
ctx := context.WithValue(r.Context(), contextKeyCustomerID, customerID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// CustomerIDFromContext extracts the customer ID stored by Middleware.
func CustomerIDFromContext(ctx context.Context) int64 {
v, _ := ctx.Value(contextKeyCustomerID).(int64)
return v
}

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

@@ -0,0 +1,88 @@
package db
import (
"database/sql"
"fmt"
"log/slog"
_ "modernc.org/sqlite"
)
// Open opens (or creates) the SQLite database and runs schema migrations.
func Open(path string) (*sql.DB, error) {
db, err := sql.Open("sqlite", path+"?_journal_mode=WAL&_foreign_keys=on")
if err != nil {
return nil, fmt.Errorf("open db: %w", err)
}
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("ping db: %w", err)
}
if err := runSchema(db); err != nil {
return nil, fmt.Errorf("schema: %w", err)
}
slog.Info("database ready", "path", path)
return db, nil
}
func runSchema(db *sql.DB) error {
stmts := []string{
`CREATE TABLE IF NOT EXISTS customers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
first_name TEXT NOT NULL DEFAULT '',
last_name TEXT NOT NULL DEFAULT '',
stripe_customer_id TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
)`,
`CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
)`,
`CREATE TABLE IF NOT EXISTS subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
stripe_subscription_id TEXT NOT NULL UNIQUE,
stripe_price_id TEXT NOT NULL DEFAULT '',
plan_name TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'active',
current_period_end TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
)`,
`CREATE TABLE IF NOT EXISTS invoices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
stripe_invoice_id TEXT NOT NULL UNIQUE,
amount_cents INTEGER NOT NULL DEFAULT 0,
currency TEXT NOT NULL DEFAULT 'usd',
status TEXT NOT NULL DEFAULT 'open',
invoice_pdf_url TEXT NOT NULL DEFAULT '',
period_start TEXT NOT NULL DEFAULT '',
period_end TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
)`,
`CREATE TABLE IF NOT EXISTS password_resets (
token TEXT PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
expires_at TEXT NOT NULL,
used INTEGER NOT NULL DEFAULT 0
)`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
return fmt.Errorf("exec schema: %w", err)
}
}
return nil
}

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

@@ -0,0 +1,94 @@
package mail
import (
"crypto/tls"
"fmt"
"net/smtp"
"os"
)
// Config holds outbound mail settings read from environment variables.
type Config struct {
Host string // SMTP_HOST
Port string // SMTP_PORT
User string // SMTP_USER
Pass string // SMTP_PASS
From string // SMTP_FROM
}
// ConfigFromEnv reads SMTP settings from environment variables.
func ConfigFromEnv() Config {
port := os.Getenv("SMTP_PORT")
if port == "" {
port = "587"
}
return Config{
Host: os.Getenv("SMTP_HOST"),
Port: port,
User: os.Getenv("SMTP_USER"),
Pass: os.Getenv("SMTP_PASS"),
From: os.Getenv("SMTP_FROM"),
}
}
// Ready reports whether all required SMTP fields are set.
func (c Config) Ready() bool {
return c.Host != "" && c.User != "" && c.Pass != "" && c.From != ""
}
// sendEmail sends a plain-text email to a single recipient.
// It tries implicit TLS first, then falls back to STARTTLS via smtp.SendMail.
func sendEmail(cfg Config, to, subject, body string) error {
addr := cfg.Host + ":" + cfg.Port
auth := smtp.PlainAuth("", cfg.User, cfg.Pass, cfg.Host)
msg := fmt.Sprintf(
"From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s",
cfg.From, to, subject, body,
)
conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: cfg.Host})
if err != nil {
return smtp.SendMail(addr, auth, cfg.From, []string{to}, []byte(msg))
}
defer conn.Close()
client, err := smtp.NewClient(conn, cfg.Host)
if err != nil {
return err
}
defer client.Close()
if err = client.Auth(auth); err != nil {
return err
}
if err = client.Mail(cfg.From); err != nil {
return err
}
if err = client.Rcpt(to); err != nil {
return err
}
wc, err := client.Data()
if err != nil {
return err
}
if _, err = fmt.Fprint(wc, msg); err != nil {
return err
}
return wc.Close()
}
// SendPasswordReset sends a password-reset email containing resetURL.
func SendPasswordReset(cfg Config, toEmail, resetURL string) error {
subject := "Reset your Arcline IT password"
body := fmt.Sprintf(
"Hello,\r\n\r\n"+
"A password reset was requested for your Arcline IT account.\r\n\r\n"+
"Click the link below to set a new password (valid for 1 hour):\r\n\r\n"+
"%s\r\n\r\n"+
"If you did not request this, you can safely ignore this email.\r\n\r\n"+
"-- Arcline IT Support\r\n",
resetURL,
)
return sendEmail(cfg, toEmail, subject, body)
}

View File

@@ -0,0 +1,216 @@
package payments
import (
"database/sql"
"encoding/json"
"fmt"
"os"
"time"
stripelib "github.com/stripe/stripe-go/v81"
"github.com/stripe/stripe-go/v81/checkout/session"
stripecustomer "github.com/stripe/stripe-go/v81/customer"
"github.com/stripe/stripe-go/v81/subscription"
)
// Config holds Stripe credentials loaded from environment variables.
type Config struct {
SecretKey string
WebhookSecret string
PriceIDs map[string]string // plan key → Stripe price ID
}
// ConfigFromEnv reads Stripe settings from environment variables.
func ConfigFromEnv() Config {
return Config{
SecretKey: os.Getenv("STRIPE_SECRET_KEY"),
WebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"),
PriceIDs: map[string]string{
"shared_starter": os.Getenv("STRIPE_PRICE_SHARED_STARTER"),
"shared_pro": os.Getenv("STRIPE_PRICE_SHARED_PRO"),
"shared_business": os.Getenv("STRIPE_PRICE_SHARED_BUSINESS"),
"wp_starter": os.Getenv("STRIPE_PRICE_WP_STARTER"),
"wp_pro": os.Getenv("STRIPE_PRICE_WP_PRO"),
"wp_business": os.Getenv("STRIPE_PRICE_WP_BUSINESS"),
"vps_1": os.Getenv("STRIPE_PRICE_VPS_1"),
"vps_2": os.Getenv("STRIPE_PRICE_VPS_2"),
"vps_3": os.Getenv("STRIPE_PRICE_VPS_3"),
"vps_4": os.Getenv("STRIPE_PRICE_VPS_4"),
},
}
}
// Ready reports whether the required Stripe credentials are set.
func (c Config) Ready() bool {
return c.SecretKey != "" && c.WebhookSecret != ""
}
// CreateCustomer creates a Stripe customer and returns the Stripe customer ID.
func CreateCustomer(email, firstName, lastName string) (string, error) {
params := &stripelib.CustomerParams{
Email: stripelib.String(email),
Name: stripelib.String(firstName + " " + lastName),
}
c, err := stripecustomer.New(params)
if err != nil {
return "", fmt.Errorf("stripe create customer: %w", err)
}
return c.ID, nil
}
// CreateCheckoutSession creates a Stripe Checkout session and returns the hosted URL.
func CreateCheckoutSession(
cfg Config,
customerID int64,
stripeCustomerID,
priceID,
successURL,
cancelURL string,
) (string, error) {
params := &stripelib.CheckoutSessionParams{
Customer: stripelib.String(stripeCustomerID),
Mode: stripelib.String(string(stripelib.CheckoutSessionModeSubscription)),
LineItems: []*stripelib.CheckoutSessionLineItemParams{
{
Price: stripelib.String(priceID),
Quantity: stripelib.Int64(1),
},
},
SuccessURL: stripelib.String(successURL),
CancelURL: stripelib.String(cancelURL),
Metadata: map[string]string{
"customer_id": fmt.Sprintf("%d", customerID),
},
}
s, err := session.New(params)
if err != nil {
return "", fmt.Errorf("stripe checkout session: %w", err)
}
return s.URL, nil
}
// CancelSubscription cancels a Stripe subscription by ID.
func CancelSubscription(stripeSubID string) error {
params := &stripelib.SubscriptionCancelParams{}
_, err := subscription.Cancel(stripeSubID, params)
if err != nil {
return fmt.Errorf("stripe cancel subscription: %w", err)
}
return nil
}
// HandleCheckoutCompleted processes a checkout.session.completed webhook event.
func HandleCheckoutCompleted(db *sql.DB, raw json.RawMessage) error {
var cs stripelib.CheckoutSession
if err := json.Unmarshal(raw, &cs); err != nil {
return fmt.Errorf("unmarshal checkout session: %w", err)
}
if cs.Subscription == nil {
return nil
}
customerIDStr, ok := cs.Metadata["customer_id"]
if !ok {
return fmt.Errorf("missing customer_id in metadata")
}
var customerID int64
fmt.Sscanf(customerIDStr, "%d", &customerID)
now := time.Now().UTC().Format(time.RFC3339)
_, err := db.Exec(
`INSERT INTO subscriptions
(customer_id, stripe_subscription_id, stripe_price_id, plan_name, status, current_period_end, created_at, updated_at)
VALUES (?, ?, ?, ?, 'active', '', ?, ?)
ON CONFLICT(stripe_subscription_id) DO UPDATE SET
status = 'active',
updated_at = excluded.updated_at`,
customerID, cs.Subscription.ID, "", "", now, now,
)
return err
}
// HandleInvoicePaid processes an invoice.paid webhook event.
func HandleInvoicePaid(db *sql.DB, raw json.RawMessage) error {
var inv stripelib.Invoice
if err := json.Unmarshal(raw, &inv); err != nil {
return fmt.Errorf("unmarshal invoice: %w", err)
}
var customerID int64
if inv.Customer != nil {
err := db.QueryRow(
`SELECT id FROM customers WHERE stripe_customer_id = ?`, inv.Customer.ID,
).Scan(&customerID)
if err != nil {
return fmt.Errorf("find customer for stripe id %s: %w", inv.Customer.ID, err)
}
}
pdfURL := ""
if inv.InvoicePDF != "" {
pdfURL = inv.InvoicePDF
}
periodStart := time.Unix(inv.PeriodStart, 0).UTC().Format(time.RFC3339)
periodEnd := time.Unix(inv.PeriodEnd, 0).UTC().Format(time.RFC3339)
now := time.Now().UTC().Format(time.RFC3339)
_, err := db.Exec(
`INSERT INTO invoices
(customer_id, stripe_invoice_id, amount_cents, currency, status, invoice_pdf_url, period_start, period_end, created_at)
VALUES (?, ?, ?, ?, 'paid', ?, ?, ?, ?)
ON CONFLICT(stripe_invoice_id) DO UPDATE SET
status = 'paid',
invoice_pdf_url = excluded.invoice_pdf_url`,
customerID, inv.ID, inv.AmountPaid, string(inv.Currency), pdfURL, periodStart, periodEnd, now,
)
if err != nil {
return fmt.Errorf("upsert invoice: %w", err)
}
if inv.Subscription != nil {
_, _ = db.Exec(
`UPDATE subscriptions SET current_period_end = ?, updated_at = ? WHERE stripe_subscription_id = ?`,
periodEnd, now, inv.Subscription.ID,
)
}
return nil
}
// HandleInvoicePaymentFailed processes an invoice.payment_failed webhook event.
func HandleInvoicePaymentFailed(db *sql.DB, raw json.RawMessage) error {
var inv stripelib.Invoice
if err := json.Unmarshal(raw, &inv); err != nil {
return fmt.Errorf("unmarshal invoice: %w", err)
}
if inv.Subscription == nil {
return nil
}
now := time.Now().UTC().Format(time.RFC3339)
_, err := db.Exec(
`UPDATE subscriptions SET status = 'past_due', updated_at = ? WHERE stripe_subscription_id = ?`,
now, inv.Subscription.ID,
)
return err
}
// HandleSubscriptionDeleted processes a customer.subscription.deleted webhook event.
func HandleSubscriptionDeleted(db *sql.DB, raw json.RawMessage) error {
var sub stripelib.Subscription
if err := json.Unmarshal(raw, &sub); err != nil {
return fmt.Errorf("unmarshal subscription: %w", err)
}
now := time.Now().UTC().Format(time.RFC3339)
_, err := db.Exec(
`UPDATE subscriptions SET status = 'cancelled', updated_at = ? WHERE stripe_subscription_id = ?`,
now, sub.ID,
)
return err
}

652
internal/web/handler.go Normal file
View File

@@ -0,0 +1,652 @@
package web
import (
"database/sql"
"embed"
"errors"
"html/template"
"io"
"log/slog"
"net/http"
"os"
"strings"
"time"
"github.com/stripe/stripe-go/v81/webhook"
"arclineit.com/billing/internal/auth"
"arclineit.com/billing/internal/mail"
"arclineit.com/billing/internal/payments"
)
//go:embed templates
var templateFS embed.FS
// Handler holds all HTTP handler dependencies.
type Handler struct {
DB *sql.DB
Stripe payments.Config
SMTP mail.Config
BaseURL string
ts *templateSet
}
// New creates a Handler and pre-parses all HTML templates.
func New(db *sql.DB, stripe payments.Config, smtp mail.Config, baseURL string) (*Handler, error) {
ts, err := loadTemplates()
if err != nil {
return nil, err
}
return &Handler{DB: db, Stripe: stripe, SMTP: smtp, BaseURL: baseURL, ts: ts}, nil
}
// ---- template set ----
type templateSet struct {
tmpl *template.Template
}
var templateFuncs = template.FuncMap{
"slice": func(start, end int, s string) string {
runes := []rune(s)
if start < 0 {
start = 0
}
if end > len(runes) {
end = len(runes)
}
if start > end {
return ""
}
return string(runes[start:end])
},
"fmtDate": func(s string) string {
if s == "" {
return "—"
}
t, err := time.Parse(time.RFC3339, s)
if err != nil {
return s
}
return t.Format("Jan 2, 2006")
},
}
func loadTemplates() (*templateSet, error) {
tmpl, err := template.New("").Funcs(templateFuncs).ParseFS(templateFS, "templates/*.html")
if err != nil {
return nil, err
}
return &templateSet{tmpl: tmpl}, nil
}
func (ts *templateSet) render(w http.ResponseWriter, name string, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := ts.tmpl.ExecuteTemplate(w, name, data); err != nil {
slog.Error("template render", "name", name, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
}
}
// ---- data models ----
type customer struct {
ID int64
Email string
FirstName string
LastName string
StripeCustomerID string
CreatedAt string
}
type subscriptionRow struct {
ID int64
StripeSubscriptionID string
StripePriceID string
PlanName string
Status string
CurrentPeriodEnd string
}
type invoiceRow struct {
ID int64
StripeInvoiceID string
AmountCents int64
Currency string
Status string
InvoicePDFURL string
PeriodStart string
PeriodEnd string
CreatedAt string
AmountDisplay string
}
type dashboardData struct {
Customer customer
Subscription *subscriptionRow
Invoices []invoiceRow
Flash string
}
// ---- DB helpers ----
func loadCustomer(db *sql.DB, id int64) (customer, error) {
var c customer
err := db.QueryRow(
`SELECT id, email, first_name, last_name, stripe_customer_id, created_at FROM customers WHERE id = ?`,
id,
).Scan(&c.ID, &c.Email, &c.FirstName, &c.LastName, &c.StripeCustomerID, &c.CreatedAt)
return c, err
}
func loadSubscription(db *sql.DB, customerID int64) (*subscriptionRow, error) {
var s subscriptionRow
err := db.QueryRow(
`SELECT id, stripe_subscription_id, stripe_price_id, plan_name, status, current_period_end
FROM subscriptions WHERE customer_id = ? ORDER BY created_at DESC LIMIT 1`,
customerID,
).Scan(&s.ID, &s.StripeSubscriptionID, &s.StripePriceID, &s.PlanName, &s.Status, &s.CurrentPeriodEnd)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return &s, err
}
func loadRecentInvoices(db *sql.DB, customerID int64) ([]invoiceRow, error) {
rows, err := db.Query(
`SELECT id, stripe_invoice_id, amount_cents, currency, status, invoice_pdf_url,
period_start, period_end, created_at
FROM invoices WHERE customer_id = ? ORDER BY created_at DESC LIMIT 5`,
customerID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var result []invoiceRow
for rows.Next() {
var inv invoiceRow
if err := rows.Scan(
&inv.ID, &inv.StripeInvoiceID, &inv.AmountCents, &inv.Currency,
&inv.Status, &inv.InvoicePDFURL, &inv.PeriodStart, &inv.PeriodEnd, &inv.CreatedAt,
); err != nil {
return nil, err
}
dollars := inv.AmountCents / 100
cents := inv.AmountCents % 100
inv.AmountDisplay = formatCurrency(dollars, cents, strings.ToUpper(inv.Currency))
result = append(result, inv)
}
return result, rows.Err()
}
func formatCurrency(dollars, cents int64, currency string) string {
if currency == "USD" || currency == "" {
return "$" + itoa(dollars) + "." + pad2(cents)
}
return itoa(dollars) + "." + pad2(cents) + " " + currency
}
func itoa(n int64) string {
if n == 0 {
return "0"
}
s := ""
neg := n < 0
if neg {
n = -n
}
for n > 0 {
s = string(rune('0'+n%10)) + s
n /= 10
}
if neg {
s = "-" + s
}
return s
}
func pad2(n int64) string {
if n < 10 {
return "0" + itoa(n)
}
return itoa(n)
}
// ---- session cookie helpers ----
func sessionSecure() bool {
return os.Getenv("SESSION_SECURE") != "false"
}
func setSessionCookie(w http.ResponseWriter, token string) {
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: token,
Path: "/",
MaxAge: int(auth.SessionTTL.Seconds()),
HttpOnly: true,
Secure: sessionSecure(),
SameSite: http.SameSiteStrictMode,
})
}
func clearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: sessionSecure(),
SameSite: http.SameSiteStrictMode,
})
}
// ---- route handlers ----
func (h *Handler) IndexHandler(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
cookie, err := r.Cookie("session")
if err != nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
_, err = auth.GetSession(h.DB, cookie.Value)
if err != nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
}
func (h *Handler) LoginGET(w http.ResponseWriter, r *http.Request) {
h.ts.render(w, "login.html", map[string]any{
"Error": r.URL.Query().Get("error"),
})
}
func (h *Handler) LoginPOST(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
email := strings.TrimSpace(strings.ToLower(r.FormValue("email")))
password := r.FormValue("password")
if email == "" || password == "" {
http.Redirect(w, r, "/login?error=missing_fields", http.StatusSeeOther)
return
}
var id int64
var hash string
err := h.DB.QueryRow(
`SELECT id, password_hash FROM customers WHERE email = ?`, email,
).Scan(&id, &hash)
if errors.Is(err, sql.ErrNoRows) || !auth.CheckPassword(hash, password) {
http.Redirect(w, r, "/login?error=invalid_credentials", http.StatusSeeOther)
return
}
if err != nil {
slog.Error("login: db query", "err", err)
http.Redirect(w, r, "/login?error=server_error", http.StatusSeeOther)
return
}
token, err := auth.CreateSession(h.DB, id)
if err != nil {
slog.Error("login: create session", "err", err)
http.Redirect(w, r, "/login?error=server_error", http.StatusSeeOther)
return
}
setSessionCookie(w, token)
slog.Info("customer logged in", "customer_id", id, "email", email)
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
}
func (h *Handler) RegisterGET(w http.ResponseWriter, r *http.Request) {
h.ts.render(w, "register.html", map[string]any{
"Error": r.URL.Query().Get("error"),
})
}
func (h *Handler) RegisterPOST(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
firstName := strings.TrimSpace(r.FormValue("first_name"))
lastName := strings.TrimSpace(r.FormValue("last_name"))
email := strings.TrimSpace(strings.ToLower(r.FormValue("email")))
password := r.FormValue("password")
confirm := r.FormValue("confirm_password")
if firstName == "" || lastName == "" || email == "" || password == "" {
http.Redirect(w, r, "/register?error=missing_fields", http.StatusSeeOther)
return
}
if password != confirm {
http.Redirect(w, r, "/register?error=password_mismatch", http.StatusSeeOther)
return
}
if len(password) < 8 {
http.Redirect(w, r, "/register?error=password_too_short", http.StatusSeeOther)
return
}
var existing int64
_ = h.DB.QueryRow(`SELECT id FROM customers WHERE email = ?`, email).Scan(&existing)
if existing != 0 {
http.Redirect(w, r, "/register?error=email_taken", http.StatusSeeOther)
return
}
hash, err := auth.HashPassword(password)
if err != nil {
slog.Error("register: hash password", "err", err)
http.Redirect(w, r, "/register?error=server_error", http.StatusSeeOther)
return
}
stripeCustomerID := ""
if h.Stripe.Ready() {
stripeCustomerID, err = payments.CreateCustomer(email, firstName, lastName)
if err != nil {
slog.Error("register: create stripe customer", "err", err)
}
}
res, err := h.DB.Exec(
`INSERT INTO customers (email, password_hash, first_name, last_name, stripe_customer_id) VALUES (?, ?, ?, ?, ?)`,
email, hash, firstName, lastName, stripeCustomerID,
)
if err != nil {
slog.Error("register: insert customer", "err", err)
http.Redirect(w, r, "/register?error=server_error", http.StatusSeeOther)
return
}
customerID, _ := res.LastInsertId()
token, err := auth.CreateSession(h.DB, customerID)
if err != nil {
slog.Error("register: create session", "err", err)
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
setSessionCookie(w, token)
slog.Info("customer registered", "customer_id", customerID, "email", email)
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
}
func (h *Handler) DashboardGET(w http.ResponseWriter, r *http.Request) {
customerID := auth.CustomerIDFromContext(r.Context())
c, err := loadCustomer(h.DB, customerID)
if err != nil {
slog.Error("dashboard: load customer", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
sub, err := loadSubscription(h.DB, customerID)
if err != nil {
slog.Error("dashboard: load subscription", "err", err)
}
invoices, err := loadRecentInvoices(h.DB, customerID)
if err != nil {
slog.Error("dashboard: load invoices", "err", err)
}
flash := ""
switch r.URL.Query().Get("checkout") {
case "success":
flash = "Your subscription is being activated. It may take a moment to appear."
case "cancelled":
flash = "Checkout was cancelled. No charge was made."
}
if r.URL.Query().Get("cancelled") == "1" {
flash = "Your subscription has been cancelled."
}
if r.URL.Query().Get("error") == "cancel_failed" {
flash = "Could not cancel subscription. Please contact support."
}
h.ts.render(w, "dashboard.html", dashboardData{
Customer: c,
Subscription: sub,
Invoices: invoices,
Flash: flash,
})
}
func (h *Handler) LogoutPOST(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session")
if err == nil {
_ = auth.DeleteSession(h.DB, cookie.Value)
}
clearSessionCookie(w)
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
func (h *Handler) ResetGET(w http.ResponseWriter, r *http.Request) {
h.ts.render(w, "reset-request.html", map[string]any{
"Sent": r.URL.Query().Get("sent"),
"Error": r.URL.Query().Get("error"),
})
}
func (h *Handler) ResetPOST(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
email := strings.TrimSpace(strings.ToLower(r.FormValue("email")))
if email == "" {
http.Redirect(w, r, "/reset?error=missing_email", http.StatusSeeOther)
return
}
var customerID int64
err := h.DB.QueryRow(`SELECT id FROM customers WHERE email = ?`, email).Scan(&customerID)
if err == nil {
token, tokenErr := auth.CreateResetToken(h.DB, customerID)
if tokenErr != nil {
slog.Error("reset: create token", "err", tokenErr)
} else if h.SMTP.Ready() {
resetURL := h.BaseURL + "/reset/" + token
if mailErr := mail.SendPasswordReset(h.SMTP, email, resetURL); mailErr != nil {
slog.Error("reset: send email", "err", mailErr)
}
} else {
slog.Warn("reset: SMTP not configured, reset token not sent", "email", email)
}
}
http.Redirect(w, r, "/reset?sent=1", http.StatusSeeOther)
}
func (h *Handler) ResetConfirmGET(w http.ResponseWriter, r *http.Request) {
token := r.PathValue("token")
h.ts.render(w, "reset-confirm.html", map[string]any{
"Token": token,
"Error": r.URL.Query().Get("error"),
})
}
func (h *Handler) ResetConfirmPOST(w http.ResponseWriter, r *http.Request) {
token := r.PathValue("token")
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
password := r.FormValue("password")
confirm := r.FormValue("confirm_password")
if password == "" {
http.Redirect(w, r, "/reset/"+token+"?error=missing_password", http.StatusSeeOther)
return
}
if password != confirm {
http.Redirect(w, r, "/reset/"+token+"?error=password_mismatch", http.StatusSeeOther)
return
}
if len(password) < 8 {
http.Redirect(w, r, "/reset/"+token+"?error=password_too_short", http.StatusSeeOther)
return
}
customerID, err := auth.ValidateResetToken(h.DB, token)
if err != nil {
slog.Warn("reset confirm: invalid token", "err", err)
http.Redirect(w, r, "/reset/"+token+"?error=invalid_token", http.StatusSeeOther)
return
}
hash, err := auth.HashPassword(password)
if err != nil {
slog.Error("reset confirm: hash password", "err", err)
http.Redirect(w, r, "/reset/"+token+"?error=server_error", http.StatusSeeOther)
return
}
_, err = h.DB.Exec(
`UPDATE customers SET password_hash = ? WHERE id = ?`, hash, customerID,
)
if err != nil {
slog.Error("reset confirm: update password", "err", err)
http.Redirect(w, r, "/reset/"+token+"?error=server_error", http.StatusSeeOther)
return
}
if err := auth.ConsumeResetToken(h.DB, token); err != nil {
slog.Error("reset confirm: consume token", "err", err)
}
_, _ = h.DB.Exec(`DELETE FROM sessions WHERE customer_id = ?`, customerID)
slog.Info("password reset completed", "customer_id", customerID)
http.Redirect(w, r, "/login?reset=1", http.StatusSeeOther)
}
func (h *Handler) CheckoutGET(w http.ResponseWriter, r *http.Request) {
customerID := auth.CustomerIDFromContext(r.Context())
priceID := r.URL.Query().Get("plan")
if priceID == "" {
http.Error(w, "missing plan parameter", http.StatusBadRequest)
return
}
var stripeCustomerID string
err := h.DB.QueryRow(
`SELECT stripe_customer_id FROM customers WHERE id = ?`, customerID,
).Scan(&stripeCustomerID)
if err != nil {
slog.Error("checkout: load customer", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
successURL := h.BaseURL + "/dashboard?checkout=success"
cancelURL := h.BaseURL + "/dashboard?checkout=cancelled"
url, err := payments.CreateCheckoutSession(h.Stripe, customerID, stripeCustomerID, priceID, successURL, cancelURL)
if err != nil {
slog.Error("checkout: create session", "err", err)
http.Error(w, "could not create checkout session", http.StatusInternalServerError)
return
}
http.Redirect(w, r, url, http.StatusSeeOther)
}
func (h *Handler) WebhookPOST(w http.ResponseWriter, r *http.Request) {
const maxBodyBytes = 65536
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
payload, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "read body failed", http.StatusBadRequest)
return
}
sig := r.Header.Get("Stripe-Signature")
event, err := webhook.ConstructEvent(payload, sig, h.Stripe.WebhookSecret)
if err != nil {
slog.Warn("webhook: signature verification failed", "err", err)
http.Error(w, "invalid signature", http.StatusBadRequest)
return
}
slog.Info("webhook received", "type", event.Type)
switch event.Type {
case "checkout.session.completed":
if err := payments.HandleCheckoutCompleted(h.DB, event.Data.Raw); err != nil {
slog.Error("webhook: checkout.session.completed", "err", err)
}
case "invoice.paid":
if err := payments.HandleInvoicePaid(h.DB, event.Data.Raw); err != nil {
slog.Error("webhook: invoice.paid", "err", err)
}
case "invoice.payment_failed":
if err := payments.HandleInvoicePaymentFailed(h.DB, event.Data.Raw); err != nil {
slog.Error("webhook: invoice.payment_failed", "err", err)
}
case "customer.subscription.deleted":
if err := payments.HandleSubscriptionDeleted(h.DB, event.Data.Raw); err != nil {
slog.Error("webhook: customer.subscription.deleted", "err", err)
}
default:
slog.Info("webhook: unhandled event type", "type", event.Type)
}
w.WriteHeader(http.StatusOK)
}
func (h *Handler) CancelPOST(w http.ResponseWriter, r *http.Request) {
customerID := auth.CustomerIDFromContext(r.Context())
var stripeSubID string
err := h.DB.QueryRow(
`SELECT stripe_subscription_id FROM subscriptions
WHERE customer_id = ? AND status = 'active'
ORDER BY created_at DESC LIMIT 1`,
customerID,
).Scan(&stripeSubID)
if err != nil {
slog.Error("cancel: find subscription", "err", err)
http.Redirect(w, r, "/dashboard?error=no_subscription", http.StatusSeeOther)
return
}
if err := payments.CancelSubscription(stripeSubID); err != nil {
slog.Error("cancel: stripe cancel", "err", err)
http.Redirect(w, r, "/dashboard?error=cancel_failed", http.StatusSeeOther)
return
}
now := time.Now().UTC().Format(time.RFC3339)
_, _ = h.DB.Exec(
`UPDATE subscriptions SET status = 'cancelled', updated_at = ? WHERE stripe_subscription_id = ?`,
now, stripeSubID,
)
slog.Info("subscription cancelled", "customer_id", customerID, "stripe_sub_id", stripeSubID)
http.Redirect(w, r, "/dashboard?cancelled=1", http.StatusSeeOther)
}

150
internal/web/middleware.go Normal file
View File

@@ -0,0 +1,150 @@
package web
import (
"context"
"log/slog"
"net"
"net/http"
"sync"
"time"
"golang.org/x/time/rate"
)
// securityHeaders are applied to every response.
var securityHeaders = map[string]string{
"Referrer-Policy": "strict-origin-when-cross-origin",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "SAMEORIGIN",
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
"Permissions-Policy": "camera=(), microphone=(), geolocation=(), interest-cohort=()",
"Content-Security-Policy": "default-src 'self'; " +
"style-src 'self' 'unsafe-inline'; " +
"font-src 'self'; " +
"script-src 'self'; " +
"img-src 'self' data:; " +
"connect-src 'self'; " +
"frame-ancestors 'none'; " +
"base-uri 'self'; " +
"form-action 'self'",
}
func applySecurityHeaders(w http.ResponseWriter) {
for k, v := range securityHeaders {
if w.Header().Get(k) == "" {
w.Header().Set(k, v)
}
}
}
// ---- per-IP rate limiter ----
type ipLimiter struct {
limiter *rate.Limiter
lastSeen time.Time
}
// RateLimiter tracks per-IP request rates.
type RateLimiter struct {
mu sync.Mutex
limiters map[string]*ipLimiter
r rate.Limit
burst int
}
// NewRateLimiter creates a RateLimiter with the given sustained rate and burst.
func NewRateLimiter(r rate.Limit, burst int) *RateLimiter {
rl := &RateLimiter{
limiters: make(map[string]*ipLimiter),
r: r,
burst: burst,
}
go rl.cleanup()
return rl
}
func (rl *RateLimiter) get(ip string) *rate.Limiter {
rl.mu.Lock()
defer rl.mu.Unlock()
entry, ok := rl.limiters[ip]
if !ok {
entry = &ipLimiter{limiter: rate.NewLimiter(rl.r, rl.burst)}
rl.limiters[ip] = entry
}
entry.lastSeen = time.Now()
return entry.limiter
}
func (rl *RateLimiter) cleanup() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
rl.mu.Lock()
for ip, e := range rl.limiters {
if time.Since(e.lastSeen) > 10*time.Minute {
delete(rl.limiters, ip)
}
}
rl.mu.Unlock()
}
}
// ---- status recorder for logging ----
type statusRecorder struct {
http.ResponseWriter
status int
wroteHeader bool
}
func (sr *statusRecorder) WriteHeader(code int) {
if !sr.wroteHeader {
sr.status = code
sr.wroteHeader = true
}
sr.ResponseWriter.WriteHeader(code)
}
func (sr *statusRecorder) Write(b []byte) (int, error) {
if !sr.wroteHeader {
sr.status = http.StatusOK
sr.wroteHeader = true
}
return sr.ResponseWriter.Write(b)
}
// BuildMiddleware wraps mux with: logging → timeout → rate-limit → security headers.
func BuildMiddleware(mux http.Handler, rl *RateLimiter, timeout time.Duration) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
ctx, cancel := context.WithTimeout(r.Context(), timeout)
defer cancel()
r = r.WithContext(ctx)
ip, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
ip = r.RemoteAddr
}
if !rl.get(ip).Allow() {
http.Error(w, "429 Too Many Requests", http.StatusTooManyRequests)
slog.Info("rate limited", "ip", ip, "path", r.URL.Path)
return
}
applySecurityHeaders(w)
sr := &statusRecorder{ResponseWriter: w}
mux.ServeHTTP(sr, r)
slog.Info("request",
"method", r.Method,
"path", r.URL.Path,
"status", sr.status,
"ip", ip,
"duration", time.Since(start).String(),
)
})
}

View File

@@ -0,0 +1,39 @@
{{define "base"}}<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{block "title" .}}Arcline IT — Client Portal{{end}}</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<nav class="nav">
<div class="nav__inner">
<a href="/" class="nav__logo">
<span class="nav__logo-bracket">[</span>arcline<span class="nav__logo-accent">IT</span><span class="nav__logo-bracket">]</span>
</a>
<span class="nav__label">client portal</span>
<div class="nav__actions">
{{block "nav-actions" .}}{{end}}
</div>
</div>
</nav>
<main class="main">
{{block "content" .}}{{end}}
</main>
<footer class="footer">
<div class="footer__inner">
<span class="footer__copy">&copy; 2026 Arcline IT</span>
<span class="footer__sep">·</span>
<a href="https://arclineit.com/tos" class="footer__link">Terms</a>
<span class="footer__sep">·</span>
<a href="https://arclineit.com/privacy" class="footer__link">Privacy</a>
<span class="footer__sep">·</span>
<a href="https://arclineit.com/contact" class="footer__link">Support</a>
</div>
</footer>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,144 @@
{{template "base" .}}
{{define "title"}}Dashboard — Arcline IT Client Portal{{end}}
{{define "nav-actions"}}
<form method="POST" action="/logout" style="display:inline;">
<button class="btn btn--ghost btn--sm" type="submit">logout</button>
</form>
{{end}}
{{define "content"}}
<div class="dashboard">
<div class="container">
{{if .Flash}}
<div class="alert alert--ok flash">{{.Flash}}</div>
{{end}}
<div class="dash-header">
<h1 class="dash-header__title">
Welcome back, <span class="dash-header__name">{{.Customer.FirstName}}</span><span class="cursor"></span>
</h1>
<p class="dash-header__email">{{.Customer.Email}}</p>
</div>
<!-- Subscription panel -->
<section class="dash-section">
<h2 class="dash-section__title">// subscription</h2>
{{if .Subscription}}
<div class="term-window">
<div class="term-window__chrome">
<span class="term-window__dot term-window__dot--red"></span>
<span class="term-window__dot term-window__dot--yellow"></span>
<span class="term-window__dot term-window__dot--green"></span>
<span class="term-window__title">subscription.status</span>
</div>
<div class="term-window__body">
<div class="status-row">
<span class="status-row__label">plan</span>
<span class="status-row__dots"></span>
<span class="status-row__value">{{if .Subscription.PlanName}}{{.Subscription.PlanName}}{{else}}active plan{{end}}</span>
</div>
<div class="status-row">
<span class="status-row__label">status</span>
<span class="status-row__dots"></span>
<span class="status-badge status-badge--{{.Subscription.Status}}">{{.Subscription.Status}}</span>
</div>
{{if .Subscription.CurrentPeriodEnd}}
<div class="status-row">
<span class="status-row__label">next billing date</span>
<span class="status-row__dots"></span>
<span class="status-row__value">{{.Subscription.CurrentPeriodEnd}}</span>
</div>
{{end}}
</div>
</div>
{{if eq .Subscription.Status "active"}}
<div class="dash-actions">
<form method="POST" action="/cancel"
onsubmit="return confirm('Are you sure you want to cancel your subscription? This cannot be undone.')">
<button class="btn btn--danger btn--sm" type="submit">cancel subscription</button>
</form>
</div>
{{end}}
{{else}}
<div class="term-window">
<div class="term-window__chrome">
<span class="term-window__dot term-window__dot--red"></span>
<span class="term-window__dot term-window__dot--yellow"></span>
<span class="term-window__dot term-window__dot--green"></span>
<span class="term-window__title">subscription.status</span>
</div>
<div class="term-window__body">
<p class="dash-empty">No active subscription.</p>
<p class="dash-empty-sub">
<a href="https://arclineit.com/pricing" class="link">View plans</a> to get started.
</p>
</div>
</div>
{{end}}
</section>
<!-- Invoices panel -->
<section class="dash-section">
<h2 class="dash-section__title">// recent invoices</h2>
{{if .Invoices}}
<div class="term-window">
<div class="term-window__chrome">
<span class="term-window__dot term-window__dot--red"></span>
<span class="term-window__dot term-window__dot--yellow"></span>
<span class="term-window__dot term-window__dot--green"></span>
<span class="term-window__title">invoices</span>
</div>
<div class="term-window__body">
<table class="inv-table">
<thead>
<tr>
<th class="inv-table__th">date</th>
<th class="inv-table__th">period</th>
<th class="inv-table__th inv-table__th--right">amount</th>
<th class="inv-table__th">status</th>
<th class="inv-table__th">pdf</th>
</tr>
</thead>
<tbody>
{{range .Invoices}}
<tr class="inv-table__row">
<td class="inv-table__td inv-table__td--muted">{{.CreatedAt | slice 0 10}}</td>
<td class="inv-table__td inv-table__td--muted">{{.PeriodStart | slice 0 10}} {{.PeriodEnd | slice 0 10}}</td>
<td class="inv-table__td inv-table__td--right">{{.AmountDisplay}}</td>
<td class="inv-table__td"><span class="status-badge status-badge--{{.Status}}">{{.Status}}</span></td>
<td class="inv-table__td">
{{if .InvoicePDFURL}}
<a href="{{.InvoicePDFURL}}" class="link link--sm" target="_blank" rel="noopener">download</a>
{{else}}—{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{else}}
<div class="term-window">
<div class="term-window__chrome">
<span class="term-window__dot term-window__dot--red"></span>
<span class="term-window__dot term-window__dot--yellow"></span>
<span class="term-window__dot term-window__dot--green"></span>
<span class="term-window__title">invoices</span>
</div>
<div class="term-window__body">
<p class="dash-empty">No invoices yet.</p>
</div>
</div>
{{end}}
</section>
</div>
</div>
{{end}}

View File

@@ -0,0 +1,49 @@
{{template "base" .}}
{{define "title"}}Sign In — Arcline IT Client Portal{{end}}
{{define "content"}}
<div class="auth-wrap">
<div class="auth-card">
<div class="auth-card__header">
<span class="auth-card__prompt">$ arcline-billing --login</span>
</div>
<div class="auth-card__body">
<h1 class="auth-card__title">Sign in to your account</h1>
{{if .Error}}
<div class="alert alert--error">
{{if eq .Error "invalid_credentials"}}Invalid email or password.
{{else if eq .Error "missing_fields"}}Please fill in all fields.
{{else if eq .Error "server_error"}}A server error occurred. Please try again.
{{else}}An error occurred. Please try again.{{end}}
</div>
{{end}}
{{if eq (index . "reset") "1"}}
<div class="alert alert--ok">Password updated. You can now sign in.</div>
{{end}}
<form method="POST" action="/login" class="form">
<div class="form__group">
<label class="form__label" for="email">Email</label>
<input class="form__input" type="email" id="email" name="email"
autocomplete="email" required placeholder="you@example.com">
</div>
<div class="form__group">
<label class="form__label" for="password">Password</label>
<input class="form__input" type="password" id="password" name="password"
autocomplete="current-password" required placeholder="••••••••">
</div>
<button class="btn btn--primary btn--full" type="submit">Sign in</button>
</form>
<div class="auth-card__links">
<a href="/reset" class="auth-card__link">Forgot password?</a>
<span class="auth-card__sep">·</span>
<a href="/register" class="auth-card__link">Create account</a>
</div>
</div>
</div>
</div>
{{end}}

View File

@@ -0,0 +1,63 @@
{{template "base" .}}
{{define "title"}}Create Account — Arcline IT Client Portal{{end}}
{{define "content"}}
<div class="auth-wrap">
<div class="auth-card">
<div class="auth-card__header">
<span class="auth-card__prompt">$ arcline-billing --register</span>
</div>
<div class="auth-card__body">
<h1 class="auth-card__title">Create your account</h1>
{{if .Error}}
<div class="alert alert--error">
{{if eq .Error "missing_fields"}}Please fill in all required fields.
{{else if eq .Error "password_mismatch"}}Passwords do not match.
{{else if eq .Error "password_too_short"}}Password must be at least 8 characters.
{{else if eq .Error "email_taken"}}An account with that email already exists.
{{else if eq .Error "server_error"}}A server error occurred. Please try again.
{{else}}An error occurred. Please try again.{{end}}
</div>
{{end}}
<form method="POST" action="/register" class="form">
<div class="form__row">
<div class="form__group">
<label class="form__label" for="first_name">First name</label>
<input class="form__input" type="text" id="first_name" name="first_name"
autocomplete="given-name" required placeholder="Blake">
</div>
<div class="form__group">
<label class="form__label" for="last_name">Last name</label>
<input class="form__input" type="text" id="last_name" name="last_name"
autocomplete="family-name" required placeholder="Smith">
</div>
</div>
<div class="form__group">
<label class="form__label" for="email">Email</label>
<input class="form__input" type="email" id="email" name="email"
autocomplete="email" required placeholder="you@example.com">
</div>
<div class="form__group">
<label class="form__label" for="password">Password <span class="form__hint">(min. 8 characters)</span></label>
<input class="form__input" type="password" id="password" name="password"
autocomplete="new-password" required placeholder="••••••••" minlength="8">
</div>
<div class="form__group">
<label class="form__label" for="confirm_password">Confirm password</label>
<input class="form__input" type="password" id="confirm_password" name="confirm_password"
autocomplete="new-password" required placeholder="••••••••" minlength="8">
</div>
<button class="btn btn--primary btn--full" type="submit">Create account</button>
</form>
<div class="auth-card__links">
<span class="auth-card__text">Already have an account?</span>
<a href="/login" class="auth-card__link">Sign in</a>
</div>
</div>
</div>
</div>
{{end}}

View File

@@ -0,0 +1,45 @@
{{template "base" .}}
{{define "title"}}Set New Password — Arcline IT Client Portal{{end}}
{{define "content"}}
<div class="auth-wrap">
<div class="auth-card">
<div class="auth-card__header">
<span class="auth-card__prompt">$ arcline-billing --set-password</span>
</div>
<div class="auth-card__body">
<h1 class="auth-card__title">Set a new password</h1>
{{if .Error}}
<div class="alert alert--error">
{{if eq .Error "invalid_token"}}This reset link is invalid or has expired.
{{else if eq .Error "missing_password"}}Please enter a new password.
{{else if eq .Error "password_mismatch"}}Passwords do not match.
{{else if eq .Error "password_too_short"}}Password must be at least 8 characters.
{{else if eq .Error "server_error"}}A server error occurred. Please try again.
{{else}}An error occurred. Please try again.{{end}}
</div>
{{end}}
<form method="POST" action="/reset/{{.Token}}" class="form">
<div class="form__group">
<label class="form__label" for="password">New password <span class="form__hint">(min. 8 characters)</span></label>
<input class="form__input" type="password" id="password" name="password"
autocomplete="new-password" required placeholder="••••••••" minlength="8">
</div>
<div class="form__group">
<label class="form__label" for="confirm_password">Confirm new password</label>
<input class="form__input" type="password" id="confirm_password" name="confirm_password"
autocomplete="new-password" required placeholder="••••••••" minlength="8">
</div>
<button class="btn btn--primary btn--full" type="submit">Update password</button>
</form>
<div class="auth-card__links">
<a href="/login" class="auth-card__link">Back to sign in</a>
</div>
</div>
</div>
</div>
{{end}}

View File

@@ -0,0 +1,48 @@
{{template "base" .}}
{{define "title"}}Reset Password — Arcline IT Client Portal{{end}}
{{define "content"}}
<div class="auth-wrap">
<div class="auth-card">
<div class="auth-card__header">
<span class="auth-card__prompt">$ arcline-billing --reset-password</span>
</div>
<div class="auth-card__body">
<h1 class="auth-card__title">Reset your password</h1>
{{if .Sent}}
<div class="alert alert--ok">
If an account exists for that email, a reset link has been sent. Check your inbox.
</div>
{{else}}
{{if .Error}}
<div class="alert alert--error">
{{if eq .Error "missing_email"}}Please enter your email address.
{{else}}An error occurred. Please try again.{{end}}
</div>
{{end}}
<p class="auth-card__desc">
Enter your account email and we'll send you a link to reset your password.
</p>
<form method="POST" action="/reset" class="form">
<div class="form__group">
<label class="form__label" for="email">Email</label>
<input class="form__input" type="email" id="email" name="email"
autocomplete="email" required placeholder="you@example.com">
</div>
<button class="btn btn--primary btn--full" type="submit">Send reset link</button>
</form>
{{end}}
<div class="auth-card__links">
<a href="/login" class="auth-card__link">Back to sign in</a>
</div>
</div>
</div>
</div>
{{end}}