Implement Issue #11: Initial admin account setup on first deploy

When the database has no users, the UI redirects to /setup and prompts
for creation of the first admin account. The setup endpoints are
self-disabling — once any user exists, POST /setup/admin returns 403
and the frontend redirects /setup back to /login. The backend uses an
atomic transaction to prevent race conditions on concurrent requests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-08 19:02:16 -03:00
parent f29d9669f8
commit 3900dff5a1
9 changed files with 585 additions and 12 deletions

68
internal/setup/setup.go Normal file
View File

@@ -0,0 +1,68 @@
package setup
import (
"context"
"database/sql"
"errors"
)
var ErrSetupAlreadyDone = errors.New("setup already completed")
// Storer is the interface for setup-related DB operations.
type Storer interface {
NeedsSetup(ctx context.Context) (bool, error)
CreateAdmin(ctx context.Context, name, email, hashedPassword string) (int64, error)
}
type Store struct {
db *sql.DB
}
func NewStore(db *sql.DB) *Store {
return &Store{db: db}
}
// NeedsSetup returns true when the volunteers table has zero rows.
func (s *Store) NeedsSetup(ctx context.Context) (bool, error) {
var count int
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM volunteers`).Scan(&count)
if err != nil {
return false, err
}
return count == 0, nil
}
// CreateAdmin atomically checks that no users exist and inserts the first admin.
func (s *Store) CreateAdmin(ctx context.Context, name, email, hashedPassword string) (int64, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
defer tx.Rollback()
var count int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM volunteers`).Scan(&count); err != nil {
return 0, err
}
if count > 0 {
return 0, ErrSetupAlreadyDone
}
res, err := tx.ExecContext(ctx,
`INSERT INTO volunteers (name, email, password, role, active, operational_roles) VALUES (?, ?, ?, 'admin', 1, '')`,
name, email, hashedPassword,
)
if err != nil {
return 0, err
}
id, err := res.LastInsertId()
if err != nil {
return 0, err
}
if err := tx.Commit(); err != nil {
return 0, err
}
return id, nil
}