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
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',
body: JSON.stringify({ email, password }),
});

View file

@ -133,7 +133,7 @@ func main() {
settingsHandler := handlers.NewSettingsHandler(userRepo, notifRepo)
analysisHandler := handlers.NewAnalysisHandler()
appealHandler := handlers.NewAppealHandler(appealService)
adminHandler := handlers.NewAdminHandler(dbPool, moderationService, appealService)
adminHandler := handlers.NewAdminHandler(dbPool, moderationService, appealService, cfg.JWTSecret)
var s3Client *s3.Client
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)
// ──────────────────────────────────────────────

View file

@ -5,29 +5,112 @@ import (
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/patbritton/sojorn-backend/internal/services"
"github.com/rs/zerolog/log"
"golang.org/x/crypto/bcrypt"
)
type AdminHandler struct {
pool *pgxpool.Pool
moderationService *services.ModerationService
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{
pool: pool,
moderationService: moderationService,
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
// ──────────────────────────────────────────────