Files
workspace/apps/go-crm/internal/handlers/auth.go
gabspereira 9c3bfd131d feat(go-crm): industrial terminal-core UI redesign
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
2026-05-23 18:00:32 -03:00

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