Add admin login endpoint (no Turnstile), use port 3002

This commit is contained in:
Patrick Britton 2026-02-06 09:33:52 -06:00
parent 896fd51dbc
commit e3d626c040
3 changed files with 97 additions and 11 deletions

View file

@ -53,7 +53,7 @@ class ApiClient {
// Auth // Auth
async login(email: string, password: string) { async login(email: string, password: string) {
const data = await this.request<{ access_token: string; user: any }>('/api/v1/auth/login', { const data = await this.request<{ access_token: string; user: any }>('/api/v1/admin/login', {
method: 'POST', method: 'POST',
body: JSON.stringify({ email, password }), body: JSON.stringify({ email, password }),
}); });

View file

@ -133,7 +133,7 @@ func main() {
settingsHandler := handlers.NewSettingsHandler(userRepo, notifRepo) settingsHandler := handlers.NewSettingsHandler(userRepo, notifRepo)
analysisHandler := handlers.NewAnalysisHandler() analysisHandler := handlers.NewAnalysisHandler()
appealHandler := handlers.NewAppealHandler(appealService) appealHandler := handlers.NewAppealHandler(appealService)
adminHandler := handlers.NewAdminHandler(dbPool, moderationService, appealService) adminHandler := handlers.NewAdminHandler(dbPool, moderationService, appealService, cfg.JWTSecret)
var s3Client *s3.Client var s3Client *s3.Client
if cfg.R2AccessKey != "" && cfg.R2SecretKey != "" && cfg.R2Endpoint != "" { if cfg.R2AccessKey != "" && cfg.R2SecretKey != "" && cfg.R2Endpoint != "" {
@ -340,6 +340,9 @@ func main() {
} }
} }
// Admin login (no auth middleware - this IS the auth step)
r.POST("/api/v1/admin/login", adminHandler.AdminLogin)
// ────────────────────────────────────────────── // ──────────────────────────────────────────────
// Admin Panel API (requires auth + admin role) // Admin Panel API (requires auth + admin role)
// ────────────────────────────────────────────── // ──────────────────────────────────────────────

View file

@ -5,29 +5,112 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"github.com/patbritton/sojorn-backend/internal/services" "github.com/patbritton/sojorn-backend/internal/services"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"golang.org/x/crypto/bcrypt"
) )
type AdminHandler struct { type AdminHandler struct {
pool *pgxpool.Pool pool *pgxpool.Pool
moderationService *services.ModerationService moderationService *services.ModerationService
appealService *services.AppealService appealService *services.AppealService
jwtSecret string
} }
func NewAdminHandler(pool *pgxpool.Pool, moderationService *services.ModerationService, appealService *services.AppealService) *AdminHandler { func NewAdminHandler(pool *pgxpool.Pool, moderationService *services.ModerationService, appealService *services.AppealService, jwtSecret string) *AdminHandler {
return &AdminHandler{ return &AdminHandler{
pool: pool, pool: pool,
moderationService: moderationService, moderationService: moderationService,
appealService: appealService, appealService: appealService,
jwtSecret: jwtSecret,
} }
} }
// ──────────────────────────────────────────────
// Admin Login (no Turnstile required)
// ──────────────────────────────────────────────
func (h *AdminHandler) AdminLogin(c *gin.Context) {
ctx := c.Request.Context()
var req struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
// Look up user
var userID uuid.UUID
var passwordHash, status string
err := h.pool.QueryRow(ctx,
`SELECT id, encrypted_password, COALESCE(status, 'active') FROM users WHERE email = $1 AND deleted_at IS NULL`,
req.Email).Scan(&userID, &passwordHash, &status)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
return
}
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.Password)); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
return
}
if status != "active" {
c.JSON(http.StatusForbidden, gin.H{"error": "Account is not active"})
return
}
// Check admin role
var role string
err = h.pool.QueryRow(ctx,
`SELECT COALESCE(role, 'user') FROM profiles WHERE id = $1`, userID).Scan(&role)
if err != nil || role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
return
}
// Generate JWT
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": userID.String(),
"exp": time.Now().Add(24 * time.Hour).Unix(),
"role": "authenticated",
})
tokenString, err := token.SignedString([]byte(h.jwtSecret))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
return
}
// Get profile info
var handle, displayName string
var avatarURL *string
h.pool.QueryRow(ctx,
`SELECT handle, display_name, avatar_url FROM profiles WHERE id = $1`, userID).Scan(&handle, &displayName, &avatarURL)
c.JSON(http.StatusOK, gin.H{
"access_token": tokenString,
"user": gin.H{
"id": userID,
"email": req.Email,
"handle": handle,
"display_name": displayName,
"avatar_url": avatarURL,
"role": role,
},
})
}
// ────────────────────────────────────────────── // ──────────────────────────────────────────────
// Dashboard / Stats // Dashboard / Stats
// ────────────────────────────────────────────── // ──────────────────────────────────────────────