diff --git a/admin/src/lib/api.ts b/admin/src/lib/api.ts index 594c1e8..c5194f6 100644 --- a/admin/src/lib/api.ts +++ b/admin/src/lib/api.ts @@ -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 }), }); diff --git a/go-backend/cmd/api/main.go b/go-backend/cmd/api/main.go index 5d5a6e0..bca2015 100644 --- a/go-backend/cmd/api/main.go +++ b/go-backend/cmd/api/main.go @@ -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) // ────────────────────────────────────────────── diff --git a/go-backend/internal/handlers/admin_handler.go b/go-backend/internal/handlers/admin_handler.go index 09087aa..40406f3 100644 --- a/go-backend/internal/handlers/admin_handler.go +++ b/go-backend/internal/handlers/admin_handler.go @@ -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 // ────────────────────────────────────────────── @@ -716,14 +799,14 @@ func (h *AdminHandler) GetModerationQueue(c *gin.Context) { "id": fID, "post_id": postID, "comment_id": commentID, "flag_reason": flagReason, "scores": scores, "status": fStatus, "reviewed_by": reviewedBy, "reviewed_at": reviewedAt, "created_at": fCreatedAt, - "content_type": contentType, - "post_body": postBody, - "post_image": postImage, - "post_video": postVideo, - "comment_body": commentBody, - "author_handle": authorHandle, - "author_name": authorDisplayName, - "post_author_id": postAuthorID, + "content_type": contentType, + "post_body": postBody, + "post_image": postImage, + "post_video": postVideo, + "comment_body": commentBody, + "author_handle": authorHandle, + "author_name": authorDisplayName, + "post_author_id": postAuthorID, "comment_author_id": commentAuthorID, }) }