diff --git a/internal/server/server.go b/internal/server/server.go index 104967c..6a9f3ae 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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)) diff --git a/internal/setup/handler.go b/internal/setup/handler.go new file mode 100644 index 0000000..2cffcc5 --- /dev/null +++ b/internal/setup/handler.go @@ -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}) +} diff --git a/internal/setup/handler_test.go b/internal/setup/handler_test.go new file mode 100644 index 0000000..3c63942 --- /dev/null +++ b/internal/setup/handler_test.go @@ -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) + } +} diff --git a/internal/setup/setup.go b/internal/setup/setup.go new file mode 100644 index 0000000..30aa77e --- /dev/null +++ b/internal/setup/setup.go @@ -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 +} diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index e1ea7eb..6f1d606 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -1,10 +1,13 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import App from './App'; +import { api } from './api'; // Mock all API calls so the app renders without a backend jest.mock('./api', () => ({ api: { + getSetupStatus: jest.fn(), + createSetupAdmin: jest.fn(), listVolunteers: jest.fn().mockResolvedValue([]), listSchedules: jest.fn().mockResolvedValue([]), listTimeOff: jest.fn().mockResolvedValue([]), @@ -14,16 +17,27 @@ jest.mock('./api', () => ({ OPERATIONAL_ROLES: [], })); -test('renders login page when unauthenticated', () => { +const mockGetSetupStatus = api.getSetupStatus as jest.Mock; + +beforeEach(() => { localStorage.clear(); - render(); - expect(screen.getByRole('heading', { name: /sign in/i })).toBeInTheDocument(); + mockGetSetupStatus.mockResolvedValue({ needs_setup: false }); }); -test('login page has email and password fields', () => { - localStorage.clear(); +test('renders login page when unauthenticated and setup done', async () => { render(); - expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); + expect(await screen.findByRole('heading', { name: /sign in/i })).toBeInTheDocument(); +}); + +test('login page has email and password fields', async () => { + render(); + expect(await screen.findByLabelText(/email/i)).toBeInTheDocument(); expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument(); }); + +test('redirects to setup when needs_setup is true', async () => { + mockGetSetupStatus.mockResolvedValue({ needs_setup: true }); + render(); + expect(await screen.findByRole('heading', { name: /initial setup/i })).toBeInTheDocument(); +}); diff --git a/web/src/App.tsx b/web/src/App.tsx index fb1f2a4..be150ba 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,8 +1,10 @@ -import React from 'react'; +import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; import { BrowserRouter, Routes, Route, NavLink, Navigate } from 'react-router-dom'; import { AuthProvider, useAuth } from './auth'; +import { api } from './api'; import Login from './pages/Login'; import Activate from './pages/Activate'; +import Setup from './pages/Setup'; import Dashboard from './pages/Dashboard'; import Schedules from './pages/Schedules'; import TimeOff from './pages/TimeOff'; @@ -50,15 +52,57 @@ function LoginRoute() { return ; } +// Setup context lets the Setup page flip needsSetup after creating the admin. +const SetupContext = createContext<{ setNeedsSetup: (v: boolean) => void }>({ + setNeedsSetup: () => {}, +}); +export const useSetup = () => useContext(SetupContext); + +function SetupGate({ children }: { children: ReactNode }) { + const [needsSetup, setNeedsSetup] = useState(null); + + useEffect(() => { + api.getSetupStatus() + .then(r => setNeedsSetup(r.needs_setup)) + .catch(() => setNeedsSetup(false)); + }, []); + + if (needsSetup === null) return null; + + if (needsSetup) { + return ( + + + } /> + } /> + + + ); + } + + return ( + + + } /> + } /> + } /> + } /> + + + ); +} + export default function App() { return ( - - } /> - } /> - } /> - + + + } /> + } /> + } /> + + ); diff --git a/web/src/api.ts b/web/src/api.ts index a9bb6e8..51d176e 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -22,6 +22,12 @@ async function request(method: string, path: string, body?: unknown): Promise } export const api = { + // Setup + getSetupStatus: () => + request<{ needs_setup: boolean }>('GET', '/setup/status'), + createSetupAdmin: (data: { name: string; email: string; password: string }) => + request<{ token: string }>('POST', '/setup/admin', data), + // Auth login: (email: string, password: string) => request<{ token: string }>('POST', '/auth/login', { email, password }), diff --git a/web/src/pages/Setup.test.tsx b/web/src/pages/Setup.test.tsx new file mode 100644 index 0000000..1370899 --- /dev/null +++ b/web/src/pages/Setup.test.tsx @@ -0,0 +1,104 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import Setup from './Setup'; +import { api } from '../api'; +import { AuthProvider } from '../auth'; + +jest.mock('../api', () => ({ + api: { + getSetupStatus: jest.fn(), + createSetupAdmin: jest.fn(), + }, +})); + +// Mock useSetup from App to provide setNeedsSetup +const mockSetNeedsSetup = jest.fn(); +jest.mock('../App', () => ({ + useSetup: () => ({ setNeedsSetup: mockSetNeedsSetup }), +})); + +const mockCreateSetupAdmin = api.createSetupAdmin as jest.Mock; + +function renderSetup() { + return render( + + + + } /> + Dashboard} /> + + + , + ); +} + +beforeEach(() => { + mockCreateSetupAdmin.mockReset(); + mockSetNeedsSetup.mockReset(); +}); + +test('renders all form fields', () => { + renderSetup(); + expect(screen.getByLabelText(/name/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/^password$/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/confirm password/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /create admin account/i })).toBeInTheDocument(); +}); + +test('shows error when passwords do not match', async () => { + renderSetup(); + fireEvent.change(screen.getByLabelText(/name/i), { target: { value: 'Admin' } }); + fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'admin@example.com' } }); + fireEvent.change(screen.getByLabelText(/^password$/i), { target: { value: 'password1' } }); + fireEvent.change(screen.getByLabelText(/confirm password/i), { target: { value: 'password2' } }); + fireEvent.click(screen.getByRole('button', { name: /create admin account/i })); + expect(await screen.findByText(/passwords do not match/i)).toBeInTheDocument(); +}); + +test('shows error when password too short', async () => { + renderSetup(); + fireEvent.change(screen.getByLabelText(/name/i), { target: { value: 'Admin' } }); + fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'admin@example.com' } }); + fireEvent.change(screen.getByLabelText(/^password$/i), { target: { value: 'short' } }); + fireEvent.change(screen.getByLabelText(/confirm password/i), { target: { value: 'short' } }); + fireEvent.click(screen.getByRole('button', { name: /create admin account/i })); + expect(await screen.findByText(/at least 8 characters/i)).toBeInTheDocument(); +}); + +test('calls api.createSetupAdmin and navigates on success', async () => { + mockCreateSetupAdmin.mockResolvedValueOnce({ token: 'jwt-token' }); + renderSetup(); + + fireEvent.change(screen.getByLabelText(/name/i), { target: { value: 'Admin' } }); + fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'admin@example.com' } }); + fireEvent.change(screen.getByLabelText(/^password$/i), { target: { value: 'goodpassword' } }); + fireEvent.change(screen.getByLabelText(/confirm password/i), { target: { value: 'goodpassword' } }); + fireEvent.click(screen.getByRole('button', { name: /create admin account/i })); + + await waitFor(() => { + expect(mockCreateSetupAdmin).toHaveBeenCalledWith({ + name: 'Admin', + email: 'admin@example.com', + password: 'goodpassword', + }); + }); + await waitFor(() => { + expect(mockSetNeedsSetup).toHaveBeenCalledWith(false); + }); + expect(await screen.findByText(/dashboard/i)).toBeInTheDocument(); +}); + +test('shows error when API returns failure', async () => { + mockCreateSetupAdmin.mockRejectedValueOnce(new Error('setup already completed')); + renderSetup(); + + fireEvent.change(screen.getByLabelText(/name/i), { target: { value: 'Admin' } }); + fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'admin@example.com' } }); + fireEvent.change(screen.getByLabelText(/^password$/i), { target: { value: 'goodpassword' } }); + fireEvent.change(screen.getByLabelText(/confirm password/i), { target: { value: 'goodpassword' } }); + fireEvent.click(screen.getByRole('button', { name: /create admin account/i })); + + expect(await screen.findByText(/setup already completed/i)).toBeInTheDocument(); +}); diff --git a/web/src/pages/Setup.tsx b/web/src/pages/Setup.tsx new file mode 100644 index 0000000..9be2767 --- /dev/null +++ b/web/src/pages/Setup.tsx @@ -0,0 +1,77 @@ +import React, { useState, FormEvent } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { api } from '../api'; +import { useAuth } from '../auth'; +import { useSetup } from '../App'; + +export default function Setup() { + const { login } = useAuth(); + const { setNeedsSetup } = useSetup(); + const navigate = useNavigate(); + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [confirm, setConfirm] = useState(''); + const [error, setError] = useState(''); + const [submitting, setSubmitting] = useState(false); + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + setError(''); + + if (!name || !email || !password) { + setError('All fields are required'); + return; + } + if (password.length < 8) { + setError('Password must be at least 8 characters'); + return; + } + if (password !== confirm) { + setError('Passwords do not match'); + return; + } + + setSubmitting(true); + try { + const { token } = await api.createSetupAdmin({ name, email, password }); + login(token); + setNeedsSetup(false); + navigate('/'); + } catch (err: any) { + setError(err.message); + } finally { + setSubmitting(false); + } + } + + return ( +
+

Walkies

+

Initial Setup

+

Create the first admin account to get started.

+
+ {error &&

{error}

} + + + + + +
+
+ ); +}