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
This commit is contained in:
2026-01-26 21:18:08 -05:00
parent 5ca2e6a732
commit 997a7318f8
+53 -1
View File
@@ -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, `<div class="text-red-500">`+err.Error()+`</div>`)
@@ -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, `<div class="text-red-500">Failed to check existing users</div>`)
}
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, `<div class="text-red-500">Invalid role. Must be 'user' or 'admin'</div>`)
}
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, `<div class="text-red-500">Cannot delete the last user account</div>`)
}
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, `<div class="text-red-500">Failed to delete account</div>`)
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}