Changeset 0.6.0 (#36)

This commit is contained in:
2026-02-20 22:24:47 +00:00
parent 30d0c11219
commit 925b70f98c
34 changed files with 2575 additions and 637 deletions

View File

@@ -6,7 +6,9 @@ import (
"database/sql"
"encoding/hex"
"fmt"
"log"
"net/http"
"os"
"strings"
"time"
@@ -91,8 +93,12 @@ func (h *AuthHandler) Register(c *gin.Context) {
_ = database.DB.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&userCount)
isFirstUser := userCount == 0
// If not first user, check if registration is enabled
if !isFirstUser {
// First-user-becomes-admin only when no env admin is configured
envAdminSet := os.Getenv("SWITCHBOARD_ADMIN_USERNAME") != ""
promoteFirst := isFirstUser && !envAdminSet
// If not first user (or env admin handles bootstrap), check registration
if !promoteFirst {
if !IsRegistrationEnabled() {
c.JSON(http.StatusForbidden, gin.H{"error": "registration is disabled"})
return
@@ -106,19 +112,25 @@ func (h *AuthHandler) Register(c *gin.Context) {
return
}
// First user gets admin role
// Determine role and active state
role := "user"
if isFirstUser {
isActive := true
if promoteFirst {
role = "admin"
} else {
// Apply registration default state
if GetRegistrationDefaultState() == "pending" {
isActive = false
}
}
// Insert user
var user userResponse
err = database.DB.QueryRow(`
INSERT INTO users (username, email, password_hash, role)
VALUES ($1, $2, $3, $4)
INSERT INTO users (username, email, password_hash, role, is_active)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, username, email, display_name, role
`, req.Username, req.Email, string(hash), role).Scan(
`, req.Username, req.Email, string(hash), role, isActive).Scan(
&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Role,
)
if err != nil {
@@ -134,6 +146,15 @@ func (h *AuthHandler) Register(c *gin.Context) {
return
}
// If account is pending, don't generate tokens
if !isActive {
c.JSON(http.StatusCreated, gin.H{
"message": "Account created and pending admin approval",
"pending": true,
})
return
}
// Generate tokens
resp, err := h.generateTokenPair(user)
if err != nil {
@@ -152,8 +173,8 @@ func IsRegistrationEnabled() bool {
}
var enabled bool
err := database.DB.QueryRow(`
SELECT COALESCE((value->>'enabled')::boolean, true)
FROM global_settings WHERE key = 'registration'
SELECT COALESCE((value->>'value')::boolean, true)
FROM global_settings WHERE key = 'registration_enabled'
`).Scan(&enabled)
if err != nil {
return true // Default to open if setting missing
@@ -161,6 +182,75 @@ func IsRegistrationEnabled() bool {
return enabled
}
// GetRegistrationDefaultState returns "active" or "pending".
func GetRegistrationDefaultState() string {
if database.DB == nil {
return "active"
}
var state string
err := database.DB.QueryRow(`
SELECT COALESCE(value->>'value', 'active')
FROM global_settings WHERE key = 'registration_default_state'
`).Scan(&state)
if err != nil || state == "" {
return "active"
}
return state
}
// BootstrapAdmin creates or updates the admin user from environment variables.
// This runs on every startup, so changing the K8s secret + restarting resets the password.
// Handles both username and email conflicts (e.g. admin username changed between deploys).
func BootstrapAdmin(cfg *config.Config) {
if cfg.AdminUsername == "" || cfg.AdminPassword == "" {
return
}
if database.DB == nil {
return
}
email := cfg.AdminEmail
if email == "" {
email = cfg.AdminUsername + "@localhost"
}
hash, err := bcrypt.GenerateFromPassword([]byte(cfg.AdminPassword), bcryptCost)
if err != nil {
log.Printf("⚠ Failed to hash admin password: %v", err)
return
}
// Try upsert by username (common case: same username, new password)
_, err = database.DB.Exec(`
INSERT INTO users (username, email, password_hash, role, is_active)
VALUES ($1, $2, $3, 'admin', true)
ON CONFLICT (username) DO UPDATE SET
password_hash = EXCLUDED.password_hash,
email = EXCLUDED.email,
role = 'admin',
is_active = true
`, cfg.AdminUsername, email, string(hash))
if err != nil && strings.Contains(err.Error(), "duplicate key") {
// Email conflict — admin username was changed in config but email
// already belongs to old admin row. Update that row instead.
_, err = database.DB.Exec(`
UPDATE users SET
username = $1,
password_hash = $3,
role = 'admin',
is_active = true
WHERE email = $2
`, cfg.AdminUsername, email, string(hash))
}
if err != nil {
log.Printf("⚠ Admin bootstrap failed: %v", err)
} else {
log.Printf("✅ Admin user '%s' bootstrapped from environment", cfg.AdminUsername)
}
}
// ── Login ───────────────────────────────────
func (h *AuthHandler) Login(c *gin.Context) {
@@ -195,7 +285,7 @@ func (h *AuthHandler) Login(c *gin.Context) {
}
if !isActive {
c.JSON(http.StatusForbidden, gin.H{"error": "account disabled"})
c.JSON(http.StatusForbidden, gin.H{"error": "account is pending admin approval"})
return
}