From 997a7318f8b568820c14f2480d164e2745bd8a87 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Mon, 26 Jan 2026 21:18:08 -0500 Subject: [PATCH] feat: Implement first-user admin and last-user protection - First registered user automatically becomes admin regardless of request - Prevent deletion of the last user account to protect system - Enhanced role validation and HTMX error handling - Proper pgx 5 database standards throughout Security improvements: - Auto-admin for first user ensures system always has administrator - Last-user protection prevents system from having zero users - Role validation ensures only 'user' or 'admin' roles accepted --- internal/handlers/auth.go | 54 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index 31572a5..393d5f4 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -35,6 +35,7 @@ type RegisterRequest struct { Password string `form:"password" json:"password" validate:"required,min=6"` FirstName string `form:"first_name" json:"first_name,omitempty"` LastName string `form:"last_name" json:"last_name,omitempty"` + Role string `form:"role" json:"role,omitempty"` } type LoginRequest struct { @@ -69,6 +70,7 @@ func (h *AuthHandler) Register(c echo.Context) error { password := c.FormValue("password") firstName := c.FormValue("first_name") lastName := c.FormValue("last_name") + role := c.FormValue("role") if email == "" || username == "" || password == "" { // Fallback to JSON binding @@ -90,9 +92,10 @@ func (h *AuthHandler) Register(c echo.Context) error { password = req.Password firstName = req.FirstName lastName = req.LastName + role = req.Role } - req := RegisterRequest{Email: email, Username: username, Password: password, FirstName: firstName, LastName: lastName} + req := RegisterRequest{Email: email, Username: username, Password: password, FirstName: firstName, LastName: lastName, Role: role} if err := c.Validate(&req); err != nil { if c.Request().Header.Get("HX-Request") == "true" { return c.HTML(http.StatusBadRequest, `
`+err.Error()+`
`) @@ -115,6 +118,15 @@ func (h *AuthHandler) Register(c echo.Context) error { return c.JSON(http.StatusConflict, map[string]string{"error": "username already exists"}) } + // Check if this is the first user - if so, make them admin regardless of request + users, err := h.db.ListUsers(c.Request().Context()) + if err != nil { + if c.Request().Header.Get("HX-Request") == "true" { + return c.HTML(http.StatusInternalServerError, `
Failed to check existing users
`) + } + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to check existing users"}) + } + // Hash password hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) if err != nil { @@ -124,6 +136,24 @@ func (h *AuthHandler) Register(c echo.Context) error { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to hash password"}) } + // Set role - first user is always admin, otherwise validate requested role + var userRole string + if len(users) == 0 { + userRole = "admin" // First user is always admin + } else { + userRole = req.Role + if userRole == "" { + userRole = "user" + } + // Validate role for subsequent users + if userRole != "user" && userRole != "admin" { + if c.Request().Header.Get("HX-Request") == "true" { + return c.HTML(http.StatusBadRequest, `
Invalid role. Must be 'user' or 'admin'
`) + } + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid role. must be 'user' or 'admin'"}) + } + } + // Create user user, err := h.db.CreateUser(c.Request().Context(), database.CreateUserParams{ Email: req.Email, @@ -131,6 +161,8 @@ func (h *AuthHandler) Register(c echo.Context) error { PasswordHash: string(hashedPassword), FirstName: pgtype.Text{String: req.FirstName, Valid: req.FirstName != ""}, LastName: pgtype.Text{String: req.LastName, Valid: req.LastName != ""}, + Theme: pgtype.Text{String: "tokyo-night", Valid: true}, // default theme + Role: userRole, }) if err != nil { if c.Request().Header.Get("HX-Request") == "true" { @@ -706,9 +738,29 @@ func (h *AuthHandler) DeleteAccount(c echo.Context) error { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"}) } + // Check if this is the last user - prevent deletion + users, err := h.db.ListUsers(c.Request().Context()) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to check existing users"}) + } + + if len(users) == 1 { + // Convert user UUIDs to string for comparison + lastUserID := uuid.UUID(users[0].ID.Bytes).String() + if lastUserID == userID { + if c.Request().Header.Get("HX-Request") == "true" { + return c.HTML(http.StatusBadRequest, `
Cannot delete the last user account
`) + } + return c.JSON(http.StatusBadRequest, map[string]string{"error": "cannot delete the last user account"}) + } + } + // Delete user (this will cascade to delete all related data) err = h.db.DeleteUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true}) if err != nil { + if c.Request().Header.Get("HX-Request") == "true" { + return c.HTML(http.StatusInternalServerError, `
Failed to delete account
`) + } return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) }