refactor(setup): derive setup-complete status from admin user count

Setup completion was previously tracked by a manually-flipped setup_complete row in system_settings, written via a JWT-protected PUT /api/setup/complete endpoint. This meant any admin user created outside the setup wizard (future CLI, seed scripts, direct DB inserts) would not flip the switch, leaving the app stuck redirecting to /setup.

The trigger is now derived from real data: setup is complete iff at least one admin user exists. This is self-correcting regardless of how users are created, and re-engages setup automatically if all admins are ever removed.

Changes:
- Add internal/setupstatus package with IsSetupComplete() (queries CountAdmins, 10s in-memory cache, fails open on DB error) and Invalidate() to clear the cache. Uses an AdminCounter interface to avoid importing the database package.
- Add CountAdmins sqlc query (SELECT COUNT(*) FROM users WHERE role = 'admin') and regenerate.
- Rewire router/setup.go isSetupComplete() to delegate to setupstatus; drop the old setup_complete setting read, cache vars, and the PUT /api/setup/complete route.
- Call setupstatus.Invalidate() in the auth handler after CreateUser, UpdateUserRole, and DeleteUser so the cache reflects admin-count changes immediately.
- Align first-user promotion in Register to key off !adminExists instead of len(users) == 0, so the two checks cannot diverge.
- Remove the now-dead SetSetupComplete/GetSetupStatus handlers.
- Drop the setup_complete seed row from schema.sql.
- Remove the apiPut('/setup/complete') call from the setup wizard finishSetup(); the admin account created in submitAdmin already marks setup complete server-side.
This commit is contained in:
2026-07-29 11:08:18 -04:00
parent acfb298b74
commit 980aaee0d9
9 changed files with 111 additions and 110 deletions
+1 -2
View File
@@ -49,8 +49,7 @@ CREATE TABLE IF NOT EXISTS system_settings (
INSERT INTO system_settings (setting_key, setting_value, description) VALUES INSERT INTO system_settings (setting_key, setting_value, description) VALUES
('scan_poll_interval_seconds', '60', 'How often to scan all libraries in minutes'), ('scan_poll_interval_seconds', '60', 'How often to scan all libraries in minutes'),
('auto_scan_enabled', 'true', 'Whether auto-scanning is enabled system-wide'), ('auto_scan_enabled', 'true', 'Whether auto-scanning is enabled system-wide'),
('default_timezone', 'UTC', 'System default timezone'), ('default_timezone', 'UTC', 'System default timezone')
('setup_complete', 'false', 'Whether the initial setup wizard has been completed')
ON CONFLICT (setting_key) DO NOTHING; ON CONFLICT (setting_key) DO NOTHING;
-- Create refresh_tokens table -- Create refresh_tokens table
+1
View File
@@ -30,6 +30,7 @@ type Querier interface {
ClearDeviceSyncQueue(ctx context.Context, deviceID pgtype.UUID) error ClearDeviceSyncQueue(ctx context.Context, deviceID pgtype.UUID) error
ClearKoboShelf(ctx context.Context, deviceID pgtype.UUID) error ClearKoboShelf(ctx context.Context, deviceID pgtype.UUID) error
ClearKoboShelfByName(ctx context.Context, arg ClearKoboShelfByNameParams) error ClearKoboShelfByName(ctx context.Context, arg ClearKoboShelfByNameParams) error
CountAdmins(ctx context.Context) (int64, error)
// Count unlinked books for a device // Count unlinked books for a device
CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error) CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error)
CountUserDevices(ctx context.Context, userID pgtype.UUID) (int64, error) CountUserDevices(ctx context.Context, userID pgtype.UUID) (int64, error)
+11
View File
@@ -226,6 +226,17 @@ func (q *Queries) ClearKoboShelfByName(ctx context.Context, arg ClearKoboShelfBy
return err return err
} }
const CountAdmins = `-- name: CountAdmins :one
SELECT COUNT(*) FROM users WHERE role = 'admin'
`
func (q *Queries) CountAdmins(ctx context.Context) (int64, error) {
row := q.db.QueryRow(ctx, CountAdmins)
var count int64
err := row.Scan(&count)
return count, err
}
const CountUnlinkedBooks = `-- name: CountUnlinkedBooks :one const CountUnlinkedBooks = `-- name: CountUnlinkedBooks :one
SELECT COUNT(*) as count SELECT COUNT(*) as count
FROM unlinked_books FROM unlinked_books
+3
View File
@@ -346,6 +346,9 @@ WHERE role = 'admin'
ORDER BY created_at ASC ORDER BY created_at ASC
LIMIT 1; LIMIT 1;
-- name: CountAdmins :one
SELECT COUNT(*) FROM users WHERE role = 'admin';
-- name: ReassignLibraries :exec -- name: ReassignLibraries :exec
UPDATE libraries SET created_by_admin_id = $2, updated_at = NOW() WHERE created_by_admin_id = $1; UPDATE libraries SET created_by_admin_id = $2, updated_at = NOW() WHERE created_by_admin_id = $1;
+28 -17
View File
@@ -6,6 +6,7 @@ package handlers
import ( import (
"bookhoard/internal/database" "bookhoard/internal/database"
"bookhoard/internal/middleware" "bookhoard/internal/middleware"
"bookhoard/internal/setupstatus"
"context" "context"
"errors" "errors"
"fmt" "fmt"
@@ -82,22 +83,22 @@ type UserProfile struct {
} }
type UpdateProfileRequest struct { type UpdateProfileRequest struct {
Username string `json:"username,omitempty" validate:"omitempty,min=3,max=50"` Username string `json:"username,omitempty" form:"username" validate:"omitempty,min=3,max=50"`
Email string `json:"email,omitempty" validate:"omitempty,email"` Email string `json:"email,omitempty" form:"email" validate:"omitempty,email"`
FirstName string `json:"first_name,omitempty" validate:"omitempty,max=100"` FirstName string `json:"first_name,omitempty" form:"first_name" validate:"omitempty,max=100"`
LastName string `json:"last_name,omitempty" validate:"omitempty,max=100"` LastName string `json:"last_name,omitempty" form:"last_name" validate:"omitempty,max=100"`
Theme string `json:"theme,omitempty" validate:"omitempty"` Theme string `json:"theme,omitempty" form:"theme" validate:"omitempty"`
Timezone string `json:"timezone,omitempty" validate:"omitempty"` Timezone string `json:"timezone,omitempty" form:"timezone" validate:"omitempty"`
} }
type AdminUpdateUserRequest struct { type AdminUpdateUserRequest struct {
Username string `json:"username,omitempty" validate:"omitempty,min=3,max=50"` Username string `json:"username,omitempty" form:"username" validate:"omitempty,min=3,max=50"`
Email string `json:"email,omitempty" validate:"omitempty,email"` Email string `json:"email,omitempty" form:"email" validate:"omitempty,email"`
FirstName string `json:"first_name,omitempty" validate:"omitempty,max=100"` FirstName string `json:"first_name,omitempty" form:"first_name" validate:"omitempty,max=100"`
LastName string `json:"last_name,omitempty" validate:"omitempty,max=100"` LastName string `json:"last_name,omitempty" form:"last_name" validate:"omitempty,max=100"`
Theme string `json:"theme,omitempty" validate:"omitempty"` Theme string `json:"theme,omitempty" form:"theme" validate:"omitempty"`
Timezone string `json:"timezone,omitempty" validate:"omitempty"` Timezone string `json:"timezone,omitempty" form:"timezone" validate:"omitempty"`
Role string `json:"role,omitempty" validate:"omitempty,oneof=user admin"` Role string `json:"role,omitempty" form:"role" validate:"omitempty,oneof=user admin"`
} }
// Register handles POST /api/auth/register // Register handles POST /api/auth/register
@@ -190,7 +191,7 @@ func (h *AuthHandler) Register(c *echo.Context) error {
} }
var userRole string var userRole string
if len(users) == 0 { if !adminExists {
userRole = "admin" userRole = "admin"
} else { } else {
userRole = req.Role userRole = req.Role
@@ -232,6 +233,10 @@ func (h *AuthHandler) Register(c *echo.Context) error {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
} }
// A new user may have changed the admin count (e.g. first user becomes
// admin), so refresh the setup-status cache.
setupstatus.Invalidate()
if err := h.CreateDefaultCollectionsForUser(c.Request().Context(), user.ID); err != nil { if err := h.CreateDefaultCollectionsForUser(c.Request().Context(), user.ID); err != nil {
if c.Request().Header.Get("HX-Request") == "true" { if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusInternalServerError, `<div class="text-red-500">Failed to create default collections</div>`) return c.HTML(http.StatusInternalServerError, `<div class="text-red-500">Failed to create default collections</div>`)
@@ -548,6 +553,9 @@ func (h *AuthHandler) UpdateProfile(c *echo.Context) error {
if err != nil { if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
} }
// Role changes can affect the admin count, so refresh the setup-status cache.
setupstatus.Invalidate()
} }
// Update username (if provided) // Update username (if provided)
@@ -785,9 +793,9 @@ func (h *AuthHandler) UpdatePassword(c *echo.Context) error {
} }
type PasswordRequest struct { type PasswordRequest struct {
CurrentPassword string `json:"current_password,omitempty"` CurrentPassword string `json:"current_password,omitempty" form:"current_password"`
NewPassword string `json:"new_password" validate:"required,passwordcomplex"` NewPassword string `json:"new_password" form:"new_password" validate:"required,passwordcomplex"`
ConfirmPassword string `json:"confirm_password" validate:"required"` ConfirmPassword string `json:"confirm_password" form:"confirm_password" validate:"required"`
} }
var req PasswordRequest var req PasswordRequest
@@ -934,6 +942,9 @@ func (h *AuthHandler) DeleteUser(c *echo.Context) error {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
} }
// Deletion may have changed the admin count, so refresh the setup-status cache.
setupstatus.Invalidate()
// Create success message based on context // Create success message based on context
var message string var message string
if targetUserID != "" && targetUserUUID.Bytes != currentUser.ID.Bytes { if targetUserID != "" && targetUserUUID.Bytes != currentUser.ID.Bytes {
-26
View File
@@ -95,32 +95,6 @@ func (h *SystemSettingsHandler) UpdateScanSettings(c *echo.Context) error {
}) })
} }
func (h *SystemSettingsHandler) SetSetupComplete(c *echo.Context) error {
err := h.db.UpdateSystemSetting(c.Request().Context(), database.UpdateSystemSettingParams{
SettingKey: "setup_complete",
SettingValue: "true",
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]string{"message": "setup complete"})
}
func (h *SystemSettingsHandler) GetSetupStatus(c *echo.Context) error {
val, err := h.db.GetSystemSetting(c.Request().Context(), "setup_complete")
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return c.JSON(http.StatusOK, map[string]bool{"setup_complete": false})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
complete, err := strconv.ParseBool(val)
if err != nil {
complete = false
}
return c.JSON(http.StatusOK, map[string]bool{"setup_complete": complete})
}
func (h *SystemSettingsHandler) GetScanSettings(c *echo.Context) error { func (h *SystemSettingsHandler) GetScanSettings(c *echo.Context) error {
scanFrequencySetting, err := h.db.GetSystemSetting(c.Request().Context(), "scan_poll_interval_seconds") scanFrequencySetting, err := h.db.GetSystemSetting(c.Request().Context(), "scan_poll_interval_seconds")
if err != nil { if err != nil {
+2 -60
View File
@@ -3,65 +3,18 @@ package router
import ( import (
"bytes" "bytes"
"context" "context"
"errors"
"log" "log"
"net/http" "net/http"
"strconv"
"strings" "strings"
"sync"
"time"
"bookhoard/internal/setupstatus"
"bookhoard/templates" "bookhoard/templates"
"github.com/jackc/pgx/v5"
"github.com/labstack/echo/v5" "github.com/labstack/echo/v5"
) )
var (
setupCacheMu sync.RWMutex
setupCacheComplete bool = true
setupCacheExpiry time.Time
setupCacheTTL = 10 * time.Second
)
func isSetupComplete(cfg *Config) bool { func isSetupComplete(cfg *Config) bool {
setupCacheMu.RLock() return setupstatus.IsSetupComplete(context.Background(), cfg.Queries)
if time.Now().Before(setupCacheExpiry) {
complete := setupCacheComplete
setupCacheMu.RUnlock()
return complete
}
setupCacheMu.RUnlock()
val, err := cfg.Queries.GetSystemSetting(context.Background(), "setup_complete")
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
setupCacheMu.Lock()
setupCacheComplete = false
setupCacheExpiry = time.Now().Add(setupCacheTTL)
setupCacheMu.Unlock()
return false
}
return true
}
complete, err := strconv.ParseBool(val)
if err != nil {
complete = false
}
setupCacheMu.Lock()
setupCacheComplete = complete
setupCacheExpiry = time.Now().Add(setupCacheTTL)
setupCacheMu.Unlock()
return complete
}
func invalidateSetupCache() {
setupCacheMu.Lock()
setupCacheComplete = true
setupCacheExpiry = time.Time{}
setupCacheMu.Unlock()
} }
func setupRedirectMiddleware(cfg *Config) echo.MiddlewareFunc { func setupRedirectMiddleware(cfg *Config) echo.MiddlewareFunc {
@@ -104,15 +57,4 @@ func registerSetupRoutes(cfg *Config) {
} }
return c.HTML(http.StatusOK, buf.String()) return c.HTML(http.StatusOK, buf.String())
}) })
jwtMiddleware := createJWTMiddleware(cfg)
protected := e.Group("/api/setup", jwtMiddleware)
protected.PUT("/complete", func(c *echo.Context) error {
err := cfg.SystemSettingsHandler.SetSetupComplete(c)
if err != nil {
return err
}
invalidateSetupCache()
return nil
})
} }
+62
View File
@@ -0,0 +1,62 @@
// Package setupstatus reports whether the application's initial setup has been
// completed. Setup is considered complete as soon as at least one admin user
// exists, regardless of how that user was created (setup wizard, API, or a
// future CLI). This keeps the setup gate a derived property of real data
// rather than a manually-flipped flag that can drift out of sync.
package setupstatus
import (
"context"
"sync"
"time"
)
// AdminCounter is satisfied by *database.Queries. It is defined as an interface
// here so this package does not import the database package, keeping the
// dependency graph flat and avoiding import cycles.
type AdminCounter interface {
CountAdmins(ctx context.Context) (int64, error)
}
var (
cacheMu sync.RWMutex
cacheComplete bool = true
cacheExpiry time.Time
cacheTTL = 10 * time.Second
)
// IsSetupComplete reports whether setup is complete. Setup is complete when at
// least one admin user exists. A short in-memory cache avoids hammering the
// database on every request. On a database error the function fails open
// (returns true) so a transient outage does not lock users out of the app.
func IsSetupComplete(ctx context.Context, q AdminCounter) bool {
cacheMu.RLock()
if time.Now().Before(cacheExpiry) {
complete := cacheComplete
cacheMu.RUnlock()
return complete
}
cacheMu.RUnlock()
count, err := q.CountAdmins(ctx)
complete := true
if err == nil {
complete = count > 0
}
cacheMu.Lock()
cacheComplete = complete
cacheExpiry = time.Now().Add(cacheTTL)
cacheMu.Unlock()
return complete
}
// Invalidate clears the cached setup status so the next call to IsSetupComplete
// re-reads from the database. Call this after any write that could change the
// admin user count (user creation, role promotion/demotion, user deletion).
func Invalidate() {
cacheMu.Lock()
cacheComplete = true
cacheExpiry = time.Time{}
cacheMu.Unlock()
}
+3 -5
View File
@@ -460,15 +460,13 @@ function initSetup(): void {
async finishSetup() { async finishSetup() {
this.loading = true; this.loading = true;
try { try {
const response = await apiPut("/setup/complete"); // Setup completion is derived from the existence of an admin user, so
await handleVoidResponse(response); // there is no separate "complete" endpoint to call. The admin account
// created in submitAdmin already marks setup as done server-side.
if (this.libraries.length > 0) { if (this.libraries.length > 0) {
setSelectedLibrary(this.libraries[0].id); setSelectedLibrary(this.libraries[0].id);
} }
window.location.href = "/dashboard"; window.location.href = "/dashboard";
} catch (err) {
handleError(err, "Failed to complete setup");
window.location.href = "/dashboard";
} finally { } finally {
this.loading = false; this.loading = false;
} }