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:
@@ -12,6 +12,7 @@ import (
|
||||
"git.unsupervised.ca/walkies/internal/notification"
|
||||
"git.unsupervised.ca/walkies/internal/schedule"
|
||||
"git.unsupervised.ca/walkies/internal/server/middleware"
|
||||
"git.unsupervised.ca/walkies/internal/setup"
|
||||
"git.unsupervised.ca/walkies/internal/timeoff"
|
||||
"git.unsupervised.ca/walkies/internal/volunteer"
|
||||
)
|
||||
@@ -34,6 +35,9 @@ func New(db *sql.DB, jwtSecret string, staticDir string) http.Handler {
|
||||
checkinStore := checkin.NewStore(db)
|
||||
checkinHandler := checkin.NewHandler(checkinStore)
|
||||
|
||||
setupStore := setup.NewStore(db)
|
||||
setupHandler := setup.NewHandler(setupStore, authSvc)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Use(chimiddleware.Logger)
|
||||
r.Use(chimiddleware.Recoverer)
|
||||
@@ -45,6 +49,10 @@ func New(db *sql.DB, jwtSecret string, staticDir string) http.Handler {
|
||||
r.Post("/auth/login", volunteerHandler.Login)
|
||||
r.Post("/auth/activate", volunteerHandler.Activate)
|
||||
|
||||
// Public setup endpoints (self-disabling once first user exists)
|
||||
r.Get("/setup/status", setupHandler.Status)
|
||||
r.Post("/setup/admin", setupHandler.CreateAdmin)
|
||||
|
||||
// Protected routes
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.Authenticate(authSvc))
|
||||
|
||||
84
internal/setup/handler.go
Normal file
84
internal/setup/handler.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"git.unsupervised.ca/walkies/internal/auth"
|
||||
"git.unsupervised.ca/walkies/internal/respond"
|
||||
)
|
||||
|
||||
// TokenIssuer is the subset of auth.Service the setup handler needs.
|
||||
type TokenIssuer interface {
|
||||
IssueToken(volunteerID int64, role string) (string, error)
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
store Storer
|
||||
authSvc TokenIssuer
|
||||
}
|
||||
|
||||
func NewHandler(store *Store, authSvc *auth.Service) *Handler {
|
||||
return &Handler{store: store, authSvc: authSvc}
|
||||
}
|
||||
|
||||
// NewHandlerFromInterfaces constructs a Handler from interface values, intended for testing.
|
||||
func NewHandlerFromInterfaces(store Storer, authSvc TokenIssuer) *Handler {
|
||||
return &Handler{store: store, authSvc: authSvc}
|
||||
}
|
||||
|
||||
// Status handles GET /api/v1/setup/status.
|
||||
func (h *Handler) Status(w http.ResponseWriter, r *http.Request) {
|
||||
needs, err := h.store.NeedsSetup(r.Context())
|
||||
if err != nil {
|
||||
respond.Error(w, http.StatusInternalServerError, "could not check setup status")
|
||||
return
|
||||
}
|
||||
respond.JSON(w, http.StatusOK, map[string]bool{"needs_setup": needs})
|
||||
}
|
||||
|
||||
// CreateAdmin handles POST /api/v1/setup/admin.
|
||||
func (h *Handler) CreateAdmin(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
respond.Error(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if body.Name == "" || body.Email == "" || body.Password == "" {
|
||||
respond.Error(w, http.StatusBadRequest, "name, email, and password are required")
|
||||
return
|
||||
}
|
||||
if len(body.Password) < 8 {
|
||||
respond.Error(w, http.StatusBadRequest, "password must be at least 8 characters")
|
||||
return
|
||||
}
|
||||
|
||||
hashed, err := auth.HashPassword(body.Password)
|
||||
if err != nil {
|
||||
respond.Error(w, http.StatusInternalServerError, "could not hash password")
|
||||
return
|
||||
}
|
||||
|
||||
id, err := h.store.CreateAdmin(r.Context(), body.Name, body.Email, hashed)
|
||||
if errors.Is(err, ErrSetupAlreadyDone) {
|
||||
respond.Error(w, http.StatusForbidden, "setup already completed")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
respond.Error(w, http.StatusInternalServerError, "could not create admin account")
|
||||
return
|
||||
}
|
||||
|
||||
token, err := h.authSvc.IssueToken(id, "admin")
|
||||
if err != nil {
|
||||
respond.Error(w, http.StatusInternalServerError, "could not issue token")
|
||||
return
|
||||
}
|
||||
|
||||
respond.JSON(w, http.StatusCreated, map[string]string{"token": token})
|
||||
}
|
||||
168
internal/setup/handler_test.go
Normal file
168
internal/setup/handler_test.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package setup_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.unsupervised.ca/walkies/internal/setup"
|
||||
)
|
||||
|
||||
// ---- fakes ---------------------------------------------------------------
|
||||
|
||||
type fakeStore struct {
|
||||
needsSetup bool
|
||||
needsSetupErr error
|
||||
createAdminID int64
|
||||
createAdminErr error
|
||||
}
|
||||
|
||||
func (f *fakeStore) NeedsSetup(_ context.Context) (bool, error) {
|
||||
return f.needsSetup, f.needsSetupErr
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreateAdmin(_ context.Context, _, _, _ string) (int64, error) {
|
||||
return f.createAdminID, f.createAdminErr
|
||||
}
|
||||
|
||||
type fakeTokenIssuer struct {
|
||||
token string
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeTokenIssuer) IssueToken(_ int64, _ string) (string, error) {
|
||||
return f.token, f.err
|
||||
}
|
||||
|
||||
// Compile-time interface checks.
|
||||
var _ setup.Storer = (*fakeStore)(nil)
|
||||
var _ setup.TokenIssuer = (*fakeTokenIssuer)(nil)
|
||||
|
||||
// ---- helpers -------------------------------------------------------------
|
||||
|
||||
func do(t *testing.T, handler http.HandlerFunc, method, path, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var b *bytes.Reader
|
||||
if body != "" {
|
||||
b = bytes.NewReader([]byte(body))
|
||||
} else {
|
||||
b = bytes.NewReader(nil)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, b)
|
||||
if body != "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
// ---- Status tests --------------------------------------------------------
|
||||
|
||||
func TestStatus_NeedsSetup(t *testing.T) {
|
||||
h := setup.NewHandlerFromInterfaces(
|
||||
&fakeStore{needsSetup: true},
|
||||
&fakeTokenIssuer{},
|
||||
)
|
||||
w := do(t, h.Status, "GET", "/api/v1/setup/status", "")
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body)
|
||||
}
|
||||
var resp map[string]bool
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if !resp["needs_setup"] {
|
||||
t.Error("expected needs_setup=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatus_SetupDone(t *testing.T) {
|
||||
h := setup.NewHandlerFromInterfaces(
|
||||
&fakeStore{needsSetup: false},
|
||||
&fakeTokenIssuer{},
|
||||
)
|
||||
w := do(t, h.Status, "GET", "/api/v1/setup/status", "")
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body)
|
||||
}
|
||||
var resp map[string]bool
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if resp["needs_setup"] {
|
||||
t.Error("expected needs_setup=false")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- CreateAdmin tests ---------------------------------------------------
|
||||
|
||||
func TestCreateAdmin_Success(t *testing.T) {
|
||||
h := setup.NewHandlerFromInterfaces(
|
||||
&fakeStore{createAdminID: 1},
|
||||
&fakeTokenIssuer{token: "jwt-token"},
|
||||
)
|
||||
w := do(t, h.CreateAdmin, "POST", "/api/v1/setup/admin",
|
||||
`{"name":"Admin","email":"admin@example.com","password":"supersecret"}`)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body)
|
||||
}
|
||||
var resp map[string]string
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if resp["token"] != "jwt-token" {
|
||||
t.Errorf("expected token jwt-token, got %q", resp["token"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAdmin_AlreadyDone(t *testing.T) {
|
||||
h := setup.NewHandlerFromInterfaces(
|
||||
&fakeStore{createAdminErr: setup.ErrSetupAlreadyDone},
|
||||
&fakeTokenIssuer{},
|
||||
)
|
||||
w := do(t, h.CreateAdmin, "POST", "/api/v1/setup/admin",
|
||||
`{"name":"Admin","email":"admin@example.com","password":"supersecret"}`)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d: %s", w.Code, w.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAdmin_MissingFields(t *testing.T) {
|
||||
h := setup.NewHandlerFromInterfaces(
|
||||
&fakeStore{},
|
||||
&fakeTokenIssuer{},
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{"missing name", `{"email":"a@b.com","password":"supersecret"}`},
|
||||
{"missing email", `{"name":"Admin","password":"supersecret"}`},
|
||||
{"missing password", `{"name":"Admin","email":"a@b.com"}`},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
w := do(t, h.CreateAdmin, "POST", "/api/v1/setup/admin", tc.body)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAdmin_PasswordTooShort(t *testing.T) {
|
||||
h := setup.NewHandlerFromInterfaces(
|
||||
&fakeStore{},
|
||||
&fakeTokenIssuer{},
|
||||
)
|
||||
w := do(t, h.CreateAdmin, "POST", "/api/v1/setup/admin",
|
||||
`{"name":"Admin","email":"admin@example.com","password":"short"}`)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body)
|
||||
}
|
||||
}
|
||||
68
internal/setup/setup.go
Normal file
68
internal/setup/setup.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user