Complete visual overhaul of the go-crm web interface: - New shared layout system (internal/templates/ui.go) with dark zinc industrial theme, JetBrains Mono typography, grid/noise textures - Redesigned all pages: Dashboard, Login/Signup, Clients, Customers, Services, Scheduling, Payments, Questions, Answers, Leads, Review Queue, Report, Keyword Mapping - Tailwind CSS via CDN with custom color palette (amber/emerald/rose/sky) - HTMX-powered interactions with CSS swap animations - Status pills, KPI cards, data tables, empty states, inline forms - Mobile-responsive sidebar with collapsible navigation - All existing tests updated and passing - Zero new build dependencies — works with existing go run/air workflow
396 lines
14 KiB
Go
396 lines
14 KiB
Go
package handlers
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"net/http"
|
|
"time"
|
|
|
|
"go-crm/internal/templates"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
// package-level globals kept for existing tests; App methods use a.DB directly.
|
|
var DB *sql.DB
|
|
|
|
func (a *App) SignupPage(w http.ResponseWriter, r *http.Request) {
|
|
content := fmt.Sprintf(`
|
|
<div class="min-h-[60vh] flex items-center justify-center">
|
|
<div class="w-full max-w-sm">
|
|
<div class="text-center mb-8">
|
|
<div class="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-amber-400/10 border border-amber-400/20 mb-4">
|
|
<svg class="w-6 h-6 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
|
|
</div>
|
|
<h1 class="text-2xl font-bold text-zinc-100 tracking-tight">go-crm</h1>
|
|
<p class="text-sm text-zinc-500 mt-1">Create your account</p>
|
|
</div>
|
|
<div class="card-industrial p-6">
|
|
<form method="POST" action="/auth/signup" class="space-y-4">
|
|
<div>
|
|
<label class="block text-[10px] font-mono uppercase tracking-wider text-zinc-500 mb-1.5">Email</label>
|
|
<input type="email" name="email" required class="input-industrial" placeholder="you@company.com">
|
|
</div>
|
|
<div>
|
|
<label class="block text-[10px] font-mono uppercase tracking-wider text-zinc-500 mb-1.5">Name</label>
|
|
<input type="text" name="name" required class="input-industrial" placeholder="Your name">
|
|
</div>
|
|
<div>
|
|
<label class="block text-[10px] font-mono uppercase tracking-wider text-zinc-500 mb-1.5">Password</label>
|
|
<input type="password" name="password" required class="input-industrial" placeholder="Min 8 characters">
|
|
</div>
|
|
<button type="submit" class="btn-primary w-full justify-center mt-2">Create Account</button>
|
|
</form>
|
|
</div>
|
|
<p class="text-center text-xs text-zinc-500 mt-6">Already have an account? <a href="/auth/login" class="text-amber-400 hover:text-amber-300 transition-colors">Sign in</a></p>
|
|
</div>
|
|
</div>`)
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
templates.WriteHTMLPage(w, "Sign Up", "", content)
|
|
}
|
|
|
|
func (a *App) Signup(w http.ResponseWriter, r *http.Request) {
|
|
r.ParseForm()
|
|
email := r.FormValue("email")
|
|
name := r.FormValue("name")
|
|
password := r.FormValue("password")
|
|
|
|
if email == "" || name == "" || password == "" {
|
|
http.Error(w, "All fields required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
http.Error(w, "Failed to hash password", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
tx, err := a.DB.Begin()
|
|
if err != nil {
|
|
http.Error(w, "Failed to start transaction", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
_, err = tx.Exec(
|
|
"INSERT INTO accounts (email, name, password, created_at) VALUES (?, ?, ?, ?)",
|
|
email, name, string(hashedPassword), time.Now().Unix(),
|
|
)
|
|
if err != nil {
|
|
http.Error(w, "Email already exists", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var accountID int64
|
|
if err = tx.QueryRow("SELECT account_id FROM accounts WHERE email = ?", email).Scan(&accountID); err != nil {
|
|
http.Error(w, "Failed to create account", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if _, err = tx.Exec(
|
|
"INSERT INTO clients (account_id, name, created_at) VALUES (?, ?, ?)",
|
|
accountID, name, time.Now().Unix(),
|
|
); err != nil {
|
|
http.Error(w, "Failed to create client", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
sessionID := generateSessionID()
|
|
expires := time.Now().Add(24 * time.Hour).Unix()
|
|
if _, err = tx.Exec(
|
|
"INSERT INTO sessions (session_id, account_id, expires) VALUES (?, ?, ?)",
|
|
sessionID, accountID, expires,
|
|
); err != nil {
|
|
http.Error(w, "Failed to create session", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if err = tx.Commit(); err != nil {
|
|
http.Error(w, "Failed to commit signup", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
http.SetCookie(w, &http.Cookie{Name: "session", Value: sessionID, Path: "/"})
|
|
http.Redirect(w, r, "/", http.StatusFound)
|
|
}
|
|
|
|
func (a *App) LoginPage(w http.ResponseWriter, r *http.Request) {
|
|
content := fmt.Sprintf(`
|
|
<div class="min-h-[60vh] flex items-center justify-center">
|
|
<div class="w-full max-w-sm">
|
|
<div class="text-center mb-8">
|
|
<div class="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-amber-400/10 border border-amber-400/20 mb-4">
|
|
<svg class="w-6 h-6 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
|
|
</div>
|
|
<h1 class="text-2xl font-bold text-zinc-100 tracking-tight">go-crm</h1>
|
|
<p class="text-sm text-zinc-500 mt-1">Sign in to your workspace</p>
|
|
</div>
|
|
<div class="card-industrial p-6">
|
|
<form method="POST" action="/auth/login" class="space-y-4">
|
|
<div>
|
|
<label class="block text-[10px] font-mono uppercase tracking-wider text-zinc-500 mb-1.5">Email</label>
|
|
<input type="email" name="email" required class="input-industrial" placeholder="you@company.com">
|
|
</div>
|
|
<div>
|
|
<label class="block text-[10px] font-mono uppercase tracking-wider text-zinc-500 mb-1.5">Password</label>
|
|
<input type="password" name="password" required class="input-industrial" placeholder="Enter your password">
|
|
</div>
|
|
<button type="submit" class="btn-primary w-full justify-center mt-2">Sign In</button>
|
|
</form>
|
|
</div>
|
|
<p class="text-center text-xs text-zinc-500 mt-6">Don't have an account? <a href="/auth/signup" class="text-amber-400 hover:text-amber-300 transition-colors">Create one</a></p>
|
|
</div>
|
|
</div>`)
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
templates.WriteHTMLPage(w, "Sign In", "", content)
|
|
}
|
|
|
|
func (a *App) Login(w http.ResponseWriter, r *http.Request) {
|
|
r.ParseForm()
|
|
email := r.FormValue("email")
|
|
password := r.FormValue("password")
|
|
|
|
var accountID int64
|
|
var hashedPassword string
|
|
err := a.DB.QueryRow("SELECT account_id, password FROM accounts WHERE email = ?", email).Scan(&accountID, &hashedPassword)
|
|
if err != nil {
|
|
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
if err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)); err != nil {
|
|
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
sessionID := generateSessionID()
|
|
expires := time.Now().Add(24 * time.Hour).Unix()
|
|
|
|
_, err = a.DB.Exec("INSERT INTO sessions (session_id, account_id, expires) VALUES (?, ?, ?)", sessionID, accountID, expires)
|
|
if err != nil {
|
|
http.Error(w, "Failed to create session", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
http.SetCookie(w, &http.Cookie{Name: "session", Value: sessionID, Path: "/"})
|
|
http.Redirect(w, r, "/", http.StatusFound)
|
|
}
|
|
|
|
func (a *App) Logout(w http.ResponseWriter, r *http.Request) {
|
|
cookie, err := r.Cookie("session")
|
|
if err == nil {
|
|
a.DB.Exec("DELETE FROM sessions WHERE session_id = ?", cookie.Value)
|
|
}
|
|
http.SetCookie(w, &http.Cookie{Name: "session", Value: "", Path: "/", MaxAge: -1})
|
|
http.Redirect(w, r, "/auth/login", http.StatusFound)
|
|
}
|
|
|
|
func (a *App) AccountPage(w http.ResponseWriter, r *http.Request) {
|
|
accountID, ok := a.requireAuth(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var name, email string
|
|
err := a.DB.QueryRow("SELECT name, email FROM accounts WHERE account_id = ?", accountID).Scan(&name, &email)
|
|
if err != nil {
|
|
http.Error(w, "Account not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
content := fmt.Sprintf(`
|
|
%s
|
|
<div class="max-w-lg animate-slide-up">
|
|
<div class="card-industrial p-6">
|
|
<form method="POST" action="/auth/account" class="space-y-4">
|
|
<div>
|
|
<label class="block text-[10px] font-mono uppercase tracking-wider text-zinc-500 mb-1.5">Name</label>
|
|
<input type="text" name="name" value="%s" class="input-industrial">
|
|
</div>
|
|
<div>
|
|
<label class="block text-[10px] font-mono uppercase tracking-wider text-zinc-500 mb-1.5">Email</label>
|
|
<input type="email" value="%s" disabled class="input-industrial opacity-50 cursor-not-allowed">
|
|
</div>
|
|
<div>
|
|
<label class="block text-[10px] font-mono uppercase tracking-wider text-zinc-500 mb-1.5">New Password</label>
|
|
<input type="password" name="password" class="input-industrial" placeholder="Leave blank to keep current">
|
|
</div>
|
|
<div class="pt-2">
|
|
<button type="submit" class="btn-primary">Update Account</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>`, templates.PageHeader("Account Settings", "Manage your profile and security"), htmlEscape(name), htmlEscape(email))
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
templates.WriteHTMLPage(w, "Account", "", content)
|
|
}
|
|
|
|
func (a *App) UpdateAccount(w http.ResponseWriter, r *http.Request) {
|
|
accountID, ok := a.requireAuth(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
r.ParseForm()
|
|
name := r.FormValue("name")
|
|
password := r.FormValue("password")
|
|
|
|
if name != "" {
|
|
a.DB.Exec("UPDATE accounts SET name = ? WHERE account_id = ?", name, accountID)
|
|
}
|
|
|
|
if password != "" {
|
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err == nil {
|
|
a.DB.Exec("UPDATE accounts SET password = ? WHERE account_id = ?", string(hashedPassword), accountID)
|
|
}
|
|
}
|
|
|
|
http.Redirect(w, r, "/auth/account", http.StatusFound)
|
|
}
|
|
|
|
// --- session helpers on App --------------------------------------------------
|
|
|
|
func (a *App) getSession(r *http.Request) (int64, error) {
|
|
cookie, err := r.Cookie("session")
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
var accountID int64
|
|
err = a.DB.QueryRow(
|
|
"SELECT account_id FROM sessions WHERE session_id = ? AND expires > ?",
|
|
cookie.Value, time.Now().Unix(),
|
|
).Scan(&accountID)
|
|
return accountID, err
|
|
}
|
|
|
|
func (a *App) GetAccountID(r *http.Request) (int64, error) {
|
|
return a.getSession(r)
|
|
}
|
|
|
|
func (a *App) requireAuth(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
|
accountID, err := a.getSession(r)
|
|
if err != nil {
|
|
if isAPIRequest(r) {
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.Write([]byte(`{"error":"unauthorized"}`))
|
|
} else {
|
|
http.Redirect(w, r, "/auth/login", http.StatusFound)
|
|
}
|
|
return 0, false
|
|
}
|
|
return accountID, true
|
|
}
|
|
|
|
func (a *App) clientIDForAccount(accountID int64) int64 {
|
|
var clientID int64
|
|
a.DB.QueryRow("SELECT client_id FROM clients WHERE account_id = ? LIMIT 1", accountID).Scan(&clientID)
|
|
return clientID
|
|
}
|
|
|
|
// --- package-level shims kept for existing tests that set the global DB ------
|
|
|
|
func GetAccountID(r *http.Request) (int64, error) {
|
|
return getSession(r)
|
|
}
|
|
|
|
func getSession(r *http.Request) (int64, error) {
|
|
cookie, err := r.Cookie("session")
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
var accountID int64
|
|
err = DB.QueryRow(
|
|
"SELECT account_id FROM sessions WHERE session_id = ? AND expires > ?",
|
|
cookie.Value, time.Now().Unix(),
|
|
).Scan(&accountID)
|
|
return accountID, err
|
|
}
|
|
|
|
func requireAuth(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
|
accountID, err := getSession(r)
|
|
if err != nil {
|
|
if isAPIRequest(r) {
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.Write([]byte(`{"error":"unauthorized"}`))
|
|
} else {
|
|
http.Redirect(w, r, "/auth/login", http.StatusFound)
|
|
}
|
|
return 0, false
|
|
}
|
|
return accountID, true
|
|
}
|
|
|
|
func clientIDForAccount(accountID int64) int64 {
|
|
var clientID int64
|
|
DB.QueryRow("SELECT client_id FROM clients WHERE account_id = ? LIMIT 1", accountID).Scan(&clientID)
|
|
return clientID
|
|
}
|
|
|
|
func isAPIRequest(r *http.Request) bool {
|
|
accept := r.Header.Get("Accept")
|
|
return accept == "application/json" || r.URL.Path == "/leads/qr"
|
|
}
|
|
|
|
func SetupAuthHandlers(db *sql.DB) {
|
|
DB = db
|
|
chi.RegisterMethod("GET")
|
|
}
|
|
|
|
// --- session ID generation ---------------------------------------------------
|
|
|
|
func generateSessionID() string {
|
|
return time.Now().Format("20060102150405") + "-" + randomString(32)
|
|
}
|
|
|
|
func randomString(n int) string {
|
|
const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
|
b := make([]byte, n)
|
|
for i := range b {
|
|
b[i] = letters[time.Now().UnixNano()%int64(len(letters))]
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// SignupPage, Signup, LoginPage, Login, Logout, AccountPage, UpdateAccount
|
|
// are also kept as package-level functions for backward compat with test
|
|
// setups that call handlers.Signup directly. They delegate to the globals.
|
|
|
|
func SignupPage(w http.ResponseWriter, r *http.Request) {
|
|
a := &App{DB: DB, WAConnector: WAConnector}
|
|
a.SignupPage(w, r)
|
|
}
|
|
|
|
func Signup(w http.ResponseWriter, r *http.Request) {
|
|
a := &App{DB: DB, WAConnector: WAConnector}
|
|
a.Signup(w, r)
|
|
}
|
|
|
|
func LoginPage(w http.ResponseWriter, r *http.Request) {
|
|
a := &App{DB: DB, WAConnector: WAConnector}
|
|
a.LoginPage(w, r)
|
|
}
|
|
|
|
func Login(w http.ResponseWriter, r *http.Request) {
|
|
a := &App{DB: DB, WAConnector: WAConnector}
|
|
a.Login(w, r)
|
|
}
|
|
|
|
func Logout(w http.ResponseWriter, r *http.Request) {
|
|
a := &App{DB: DB, WAConnector: WAConnector}
|
|
a.Logout(w, r)
|
|
}
|
|
|
|
func AccountPage(w http.ResponseWriter, r *http.Request) {
|
|
a := &App{DB: DB, WAConnector: WAConnector}
|
|
a.AccountPage(w, r)
|
|
}
|
|
|
|
func UpdateAccount(w http.ResponseWriter, r *http.Request) {
|
|
a := &App{DB: DB, WAConnector: WAConnector}
|
|
a.UpdateAccount(w, r)
|
|
}
|