Implement Issue #1: User Accounts & Profiles
Some checks failed
CI / Go tests & lint (push) Successful in 7s
CI / Frontend tests & type-check (push) Failing after 9s
CI / Go tests & lint (pull_request) Successful in 8s
CI / Frontend tests & type-check (pull_request) Failing after 9s

- Admin-only account creation (no self-registration); invite-token flow
  replaces the public /auth/register endpoint
- New volunteer fields: phone, is_trainee, operational_roles,
  notification_preference, admin_notes, last_login, completed_shifts
- Role-scoped profile editing: volunteers update name/phone only;
  admins update all fields including notes and trainee flag
- /auth/activate endpoint for invite-token-based account activation
- /api/v1/volunteers/{id}/invite for admin to resend invite links
- last_login recorded on each successful authentication

Tests:
- Go: handler tests (auth rules, create, activate, update scoping) via
  Storer/AuthServicer interfaces and fake store; auth unit tests for
  HashPassword, IssueToken, and Parse
- Frontend: RTL tests for Activate, Profile, and Volunteers pages
- Fixed CRA 5 + React Router v7 Jest compatibility (moduleNameMapper +
  TextEncoder polyfill)
- Replaced stale CRA App.test.tsx placeholder with real tests

CI:
- .gitea/workflows/ci.yml runs go vet, go test, tsc, and npm test on
  every push and pull request

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-07 10:53:39 -03:00
parent c2b0a4fea2
commit 6c9746eb05
21 changed files with 1892 additions and 101 deletions

49
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,49 @@
name: CI
on:
push:
branches: ["**"]
pull_request:
branches: ["**"]
jobs:
go:
name: Go tests & lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: go vet
run: go vet ./...
- name: go test
run: go test ./...
web:
name: Frontend tests & type-check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .node-version
cache: npm
cache-dependency-path: web/package-lock.json
- name: Install dependencies
working-directory: web
run: npm ci
- name: Type check
working-directory: web
run: npx tsc --noEmit
- name: Run tests
working-directory: web
run: CI=true npm test -- --watchAll=false

1
.node-version Normal file
View File

@@ -0,0 +1 @@
22

View File

@@ -28,23 +28,25 @@ func NewService(db *sql.DB, secret string) *Service {
return &Service{db: db, jwtSecret: []byte(secret)}
}
func (s *Service) Login(ctx context.Context, email, password string) (string, error) {
// Login authenticates by email/password and returns the volunteer ID and JWT token.
func (s *Service) Login(ctx context.Context, email, password string) (int64, string, error) {
var id int64
var hash, role string
err := s.db.QueryRowContext(ctx,
`SELECT id, password, role FROM volunteers WHERE email = ? AND active = 1`,
`SELECT id, password, role FROM volunteers WHERE email = ? AND active = 1 AND (password != '' AND password IS NOT NULL)`,
email,
).Scan(&id, &hash, &role)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrInvalidCredentials
return 0, "", ErrInvalidCredentials
}
if err != nil {
return "", fmt.Errorf("query volunteer: %w", err)
return 0, "", fmt.Errorf("query volunteer: %w", err)
}
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
return "", ErrInvalidCredentials
return 0, "", ErrInvalidCredentials
}
return s.issueToken(id, role)
token, err := s.issueToken(id, role)
return id, token, err
}
func (s *Service) issueToken(volunteerID int64, role string) (string, error) {
@@ -81,3 +83,9 @@ func HashPassword(password string) (string, error) {
b, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(b), err
}
// IssueToken mints a JWT for the given volunteer ID and role without querying the DB.
// Intended for use in tests and invite-activation flows.
func (s *Service) IssueToken(volunteerID int64, role string) (string, error) {
return s.issueToken(volunteerID, role)
}

View File

@@ -0,0 +1,92 @@
package auth_test
import (
"testing"
"git.unsupervised.ca/walkies/internal/auth"
)
func TestHashPassword_RoundTrip(t *testing.T) {
hash, err := auth.HashPassword("mysecret")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
if hash == "mysecret" {
t.Error("hash should not equal plaintext")
}
if len(hash) == 0 {
t.Error("hash should not be empty")
}
}
func TestHashPassword_DifferentInputs(t *testing.T) {
h1, _ := auth.HashPassword("password1")
h2, _ := auth.HashPassword("password2")
if h1 == h2 {
t.Error("different passwords should produce different hashes")
}
}
func TestIssueToken_Parse_RoundTrip(t *testing.T) {
svc := auth.NewService(nil, "test-secret")
token, err := svc.IssueToken(42, "admin")
if err != nil {
t.Fatalf("IssueToken: %v", err)
}
if token == "" {
t.Fatal("expected non-empty token")
}
claims, err := svc.Parse(token)
if err != nil {
t.Fatalf("Parse: %v", err)
}
if claims.VolunteerID != 42 {
t.Errorf("expected volunteer_id 42, got %d", claims.VolunteerID)
}
if claims.Role != "admin" {
t.Errorf("expected role admin, got %q", claims.Role)
}
}
func TestParse_InvalidToken(t *testing.T) {
svc := auth.NewService(nil, "test-secret")
_, err := svc.Parse("not.a.token")
if err == nil {
t.Error("expected error parsing invalid token")
}
}
func TestParse_WrongSecret(t *testing.T) {
svc1 := auth.NewService(nil, "secret-A")
svc2 := auth.NewService(nil, "secret-B")
token, err := svc1.IssueToken(1, "volunteer")
if err != nil {
t.Fatalf("IssueToken: %v", err)
}
_, err = svc2.Parse(token)
if err == nil {
t.Error("token signed with secret-A should not parse with secret-B")
}
}
func TestIssueToken_Volunteer(t *testing.T) {
svc := auth.NewService(nil, "test-secret")
token, err := svc.IssueToken(7, "volunteer")
if err != nil {
t.Fatalf("IssueToken: %v", err)
}
claims, err := svc.Parse(token)
if err != nil {
t.Fatalf("Parse: %v", err)
}
if claims.Role != "volunteer" {
t.Errorf("expected role volunteer, got %q", claims.Role)
}
if claims.VolunteerID != 7 {
t.Errorf("expected volunteer_id 7, got %d", claims.VolunteerID)
}
}

View File

@@ -2,15 +2,32 @@ package db
var statements = []string{
`CREATE TABLE IF NOT EXISTS volunteers (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
role VARCHAR(50) NOT NULL DEFAULT 'volunteer' COMMENT 'admin or volunteer',
active TINYINT NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL DEFAULT '',
role VARCHAR(50) NOT NULL DEFAULT 'volunteer' COMMENT 'admin or volunteer',
active TINYINT NOT NULL DEFAULT 1,
is_trainee TINYINT NOT NULL DEFAULT 0,
phone VARCHAR(20) NULL,
operational_roles TEXT NOT NULL DEFAULT '',
notification_preference VARCHAR(50) NOT NULL DEFAULT 'email',
admin_notes TEXT NULL,
last_login DATETIME NULL,
invite_token VARCHAR(255) NULL,
invite_expires_at DATETIME NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
// Additive column migrations for existing deployments
`ALTER TABLE volunteers ADD COLUMN IF NOT EXISTS is_trainee TINYINT NOT NULL DEFAULT 0`,
`ALTER TABLE volunteers ADD COLUMN IF NOT EXISTS phone VARCHAR(20) NULL`,
`ALTER TABLE volunteers ADD COLUMN IF NOT EXISTS operational_roles TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE volunteers ADD COLUMN IF NOT EXISTS notification_preference VARCHAR(50) NOT NULL DEFAULT 'email'`,
`ALTER TABLE volunteers ADD COLUMN IF NOT EXISTS admin_notes TEXT NULL`,
`ALTER TABLE volunteers ADD COLUMN IF NOT EXISTS last_login DATETIME NULL`,
`ALTER TABLE volunteers ADD COLUMN IF NOT EXISTS invite_token VARCHAR(255) NULL`,
`ALTER TABLE volunteers ADD COLUMN IF NOT EXISTS invite_expires_at DATETIME NULL`,
`CREATE TABLE IF NOT EXISTS schedules (
id INT AUTO_INCREMENT PRIMARY KEY,
volunteer_id INT NOT NULL,

View File

@@ -42,17 +42,19 @@ func New(db *sql.DB, jwtSecret string, staticDir string) http.Handler {
// API routes
r.Route("/api/v1", func(r chi.Router) {
// Public auth endpoints
r.Post("/auth/register", volunteerHandler.Register)
r.Post("/auth/login", volunteerHandler.Login)
r.Post("/auth/activate", volunteerHandler.Activate)
// Protected routes
r.Group(func(r chi.Router) {
r.Use(middleware.Authenticate(authSvc))
// Volunteers
r.With(middleware.RequireAdmin).Post("/volunteers", volunteerHandler.Create)
r.Get("/volunteers", volunteerHandler.List)
r.Get("/volunteers/{id}", volunteerHandler.Get)
r.With(middleware.RequireAdmin).Put("/volunteers/{id}", volunteerHandler.Update)
r.Put("/volunteers/{id}", volunteerHandler.Update)
r.With(middleware.RequireAdmin).Post("/volunteers/{id}/invite", volunteerHandler.ResendInvite)
// Schedules
r.Get("/schedules", scheduleHandler.List)

View File

@@ -1,6 +1,7 @@
package volunteer
import (
"context"
"encoding/json"
"errors"
"net/http"
@@ -8,43 +9,27 @@ import (
"git.unsupervised.ca/walkies/internal/auth"
"git.unsupervised.ca/walkies/internal/respond"
"git.unsupervised.ca/walkies/internal/server/middleware"
"github.com/go-chi/chi/v5"
)
// AuthServicer is the subset of auth.Service the Handler needs.
type AuthServicer interface {
Login(ctx context.Context, email, password string) (int64, string, error)
}
type Handler struct {
store *Store
authSvc *auth.Service
store Storer
authSvc AuthServicer
}
func NewHandler(store *Store, authSvc *auth.Service) *Handler {
return &Handler{store: store, authSvc: authSvc}
}
// POST /api/v1/auth/register
func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
var in CreateInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
respond.Error(w, http.StatusBadRequest, "invalid request body")
return
}
if in.Name == "" || in.Email == "" || in.Password == "" {
respond.Error(w, http.StatusBadRequest, "name, email, and password are required")
return
}
if in.Role == "" {
in.Role = "volunteer"
}
hash, err := auth.HashPassword(in.Password)
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not hash password")
return
}
v, err := h.store.Create(r.Context(), in.Name, in.Email, hash, in.Role)
if err != nil {
respond.Error(w, http.StatusConflict, "email already in use")
return
}
respond.JSON(w, http.StatusCreated, v)
// NewHandlerFromInterfaces constructs a Handler from interface values, intended for testing.
func NewHandlerFromInterfaces(store Storer, authSvc AuthServicer) *Handler {
return &Handler{store: store, authSvc: authSvc}
}
// POST /api/v1/auth/login
@@ -57,16 +42,82 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
respond.Error(w, http.StatusBadRequest, "invalid request body")
return
}
token, err := h.authSvc.Login(r.Context(), body.Email, body.Password)
id, token, err := h.authSvc.Login(r.Context(), body.Email, body.Password)
if err != nil {
respond.Error(w, http.StatusUnauthorized, "invalid credentials")
return
}
_ = h.store.RecordLogin(r.Context(), id)
respond.JSON(w, http.StatusOK, map[string]string{"token": token})
}
// POST /api/v1/auth/activate
// Public endpoint — volunteer sets their password using an invite token.
func (h *Handler) Activate(w http.ResponseWriter, r *http.Request) {
var body struct {
Token string `json:"token"`
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.Token == "" || body.Password == "" {
respond.Error(w, http.StatusBadRequest, "token and password are required")
return
}
hashed, err := auth.HashPassword(body.Password)
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not hash password")
return
}
v, err := h.store.Activate(r.Context(), body.Token, hashed)
if errors.Is(err, ErrInvalidToken) {
respond.Error(w, http.StatusBadRequest, "invalid or expired invite token")
return
}
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not activate account")
return
}
respond.JSON(w, http.StatusOK, v)
}
// POST /api/v1/volunteers — admin only
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
var in CreateInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
respond.Error(w, http.StatusBadRequest, "invalid request body")
return
}
if in.Name == "" || in.Email == "" {
respond.Error(w, http.StatusBadRequest, "name and email are required")
return
}
av, err := h.store.Create(r.Context(), in)
if err != nil {
respond.Error(w, http.StatusConflict, "email already in use")
return
}
respond.JSON(w, http.StatusCreated, av)
}
// GET /api/v1/volunteers
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
if claims != nil && claims.Role == "admin" {
volunteers, err := h.store.ListAdmin(r.Context())
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not list volunteers")
return
}
if volunteers == nil {
volunteers = []AdminVolunteer{}
}
respond.JSON(w, http.StatusOK, volunteers)
return
}
volunteers, err := h.store.List(r.Context(), true)
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not list volunteers")
@@ -85,6 +136,21 @@ func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
respond.Error(w, http.StatusBadRequest, "invalid id")
return
}
claims := middleware.ClaimsFromContext(r.Context())
if claims != nil && claims.Role == "admin" {
av, err := h.store.GetAdminByID(r.Context(), id)
if errors.Is(err, ErrNotFound) {
respond.Error(w, http.StatusNotFound, "volunteer not found")
return
}
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not get volunteer")
return
}
respond.JSON(w, http.StatusOK, av)
return
}
v, err := h.store.GetByID(r.Context(), id)
if errors.Is(err, ErrNotFound) {
respond.Error(w, http.StatusNotFound, "volunteer not found")
@@ -98,17 +164,35 @@ func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
}
// PUT /api/v1/volunteers/{id}
// Admins can update all fields. Volunteers can only update their own name and phone.
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
respond.Error(w, http.StatusBadRequest, "invalid id")
return
}
claims := middleware.ClaimsFromContext(r.Context())
if claims == nil {
respond.Error(w, http.StatusUnauthorized, "unauthorized")
return
}
var in UpdateInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
respond.Error(w, http.StatusBadRequest, "invalid request body")
return
}
// Volunteers can only update their own profile, and only name + phone.
if claims.Role != "admin" {
if claims.VolunteerID != id {
respond.Error(w, http.StatusForbidden, "forbidden")
return
}
restricted := UpdateInput{Name: in.Name, Phone: in.Phone}
in = restricted
}
v, err := h.store.Update(r.Context(), id, in)
if errors.Is(err, ErrNotFound) {
respond.Error(w, http.StatusNotFound, "volunteer not found")
@@ -120,3 +204,18 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
}
respond.JSON(w, http.StatusOK, v)
}
// POST /api/v1/volunteers/{id}/invite — admin only, resends invite token
func (h *Handler) ResendInvite(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
respond.Error(w, http.StatusBadRequest, "invalid id")
return
}
token, err := h.store.RotateInviteToken(r.Context(), id)
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not generate invite token")
return
}
respond.JSON(w, http.StatusOK, map[string]string{"invite_token": token})
}

View File

@@ -0,0 +1,442 @@
package volunteer_test
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.unsupervised.ca/walkies/internal/auth"
"git.unsupervised.ca/walkies/internal/server/middleware"
"git.unsupervised.ca/walkies/internal/volunteer"
"github.com/go-chi/chi/v5"
)
// ---- fakes ---------------------------------------------------------------
type fakeStore struct {
volunteer *volunteer.Volunteer
adminVol *volunteer.AdminVolunteer
adminList []volunteer.AdminVolunteer
volList []volunteer.Volunteer
activateErr error
createErr error
updateResult *volunteer.Volunteer
updateErr error
inviteToken string
recordCalled bool
}
func (f *fakeStore) Create(_ context.Context, in volunteer.CreateInput) (*volunteer.AdminVolunteer, error) {
if f.createErr != nil {
return nil, f.createErr
}
tok := "test-invite-token"
return &volunteer.AdminVolunteer{
Volunteer: volunteer.Volunteer{ID: 99, Name: in.Name, Email: in.Email, Role: "volunteer", Active: true},
InviteToken: &tok,
}, nil
}
func (f *fakeStore) GetByID(_ context.Context, id int64) (*volunteer.Volunteer, error) {
if f.volunteer != nil && f.volunteer.ID == id {
return f.volunteer, nil
}
return nil, volunteer.ErrNotFound
}
func (f *fakeStore) GetAdminByID(_ context.Context, id int64) (*volunteer.AdminVolunteer, error) {
if f.adminVol != nil && f.adminVol.ID == id {
return f.adminVol, nil
}
return nil, volunteer.ErrNotFound
}
func (f *fakeStore) List(_ context.Context, _ bool) ([]volunteer.Volunteer, error) {
return f.volList, nil
}
func (f *fakeStore) ListAdmin(_ context.Context) ([]volunteer.AdminVolunteer, error) {
return f.adminList, nil
}
func (f *fakeStore) Update(_ context.Context, id int64, in volunteer.UpdateInput) (*volunteer.Volunteer, error) {
if f.updateErr != nil {
return nil, f.updateErr
}
if f.updateResult != nil {
return f.updateResult, nil
}
v := &volunteer.Volunteer{ID: id, Name: "Updated", Role: "volunteer", Active: true}
return v, nil
}
func (f *fakeStore) GetByInviteToken(_ context.Context, token string) (*volunteer.Volunteer, error) {
if token == "valid-token" {
return &volunteer.Volunteer{ID: 1, Name: "Alice", Email: "alice@example.com", Active: true}, nil
}
return nil, volunteer.ErrInvalidToken
}
func (f *fakeStore) Activate(_ context.Context, token, _ string) (*volunteer.Volunteer, error) {
if f.activateErr != nil {
return nil, f.activateErr
}
if token != "valid-token" {
return nil, volunteer.ErrInvalidToken
}
return &volunteer.Volunteer{ID: 1, Name: "Alice", Email: "alice@example.com", Active: true}, nil
}
func (f *fakeStore) RotateInviteToken(_ context.Context, _ int64) (string, error) {
if f.inviteToken != "" {
return f.inviteToken, nil
}
return "new-invite-token", nil
}
func (f *fakeStore) RecordLogin(_ context.Context, _ int64) error {
f.recordCalled = true
return nil
}
type fakeAuthSvc struct {
id int64
token string
err error
}
func (f *fakeAuthSvc) Login(_ context.Context, _, _ string) (int64, string, error) {
return f.id, f.token, f.err
}
// ---- helpers -------------------------------------------------------------
func jwtForRole(t *testing.T, id int64, role string) string {
t.Helper()
svc := auth.NewService(nil, "test-secret")
token, err := svc.IssueToken(id, role)
if err != nil {
t.Fatalf("issue token: %v", err)
}
return token
}
func newRouter(h *volunteer.Handler, authSvc *auth.Service) http.Handler {
r := chi.NewRouter()
r.Post("/api/v1/auth/login", h.Login)
r.Post("/api/v1/auth/activate", h.Activate)
r.Group(func(r chi.Router) {
r.Use(middleware.Authenticate(authSvc))
r.Post("/api/v1/volunteers", middleware.RequireAdmin(http.HandlerFunc(h.Create)).ServeHTTP)
r.Get("/api/v1/volunteers", h.List)
r.Get("/api/v1/volunteers/{id}", h.Get)
r.Put("/api/v1/volunteers/{id}", h.Update)
r.Post("/api/v1/volunteers/{id}/invite",
middleware.RequireAdmin(http.HandlerFunc(h.ResendInvite)).ServeHTTP)
})
return r
}
func do(t *testing.T, router http.Handler, method, path, body, token 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")
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
// ---- tests ----------------------------------------------------------------
func TestLogin_Success(t *testing.T) {
store := &fakeStore{}
authSvc := &fakeAuthSvc{id: 1, token: "jwt-token"}
realAuthSvc := auth.NewService(nil, "test-secret")
h := volunteer.NewHandlerFromInterfaces(store, authSvc)
router := newRouter(h, realAuthSvc)
w := do(t, router, "POST", "/api/v1/auth/login",
`{"email":"a@b.com","password":"pass"}`, "")
if w.Code != http.StatusOK {
t.Fatalf("expected 200, 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"])
}
if !store.recordCalled {
t.Error("RecordLogin was not called after successful login")
}
}
func TestLogin_InvalidCredentials(t *testing.T) {
store := &fakeStore{}
authSvc := &fakeAuthSvc{err: auth.ErrInvalidCredentials}
realAuthSvc := auth.NewService(nil, "test-secret")
h := volunteer.NewHandlerFromInterfaces(store, authSvc)
router := newRouter(h, realAuthSvc)
w := do(t, router, "POST", "/api/v1/auth/login",
`{"email":"a@b.com","password":"wrong"}`, "")
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", w.Code)
}
}
func TestActivate_ValidToken(t *testing.T) {
store := &fakeStore{}
realAuthSvc := auth.NewService(nil, "test-secret")
h := volunteer.NewHandlerFromInterfaces(store, &fakeAuthSvc{})
router := newRouter(h, realAuthSvc)
w := do(t, router, "POST", "/api/v1/auth/activate",
`{"token":"valid-token","password":"supersecret"}`, "")
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body)
}
}
func TestActivate_InvalidToken(t *testing.T) {
store := &fakeStore{activateErr: volunteer.ErrInvalidToken}
realAuthSvc := auth.NewService(nil, "test-secret")
h := volunteer.NewHandlerFromInterfaces(store, &fakeAuthSvc{})
router := newRouter(h, realAuthSvc)
w := do(t, router, "POST", "/api/v1/auth/activate",
`{"token":"bad-token","password":"supersecret"}`, "")
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body)
}
}
func TestActivate_MissingFields(t *testing.T) {
store := &fakeStore{}
realAuthSvc := auth.NewService(nil, "test-secret")
h := volunteer.NewHandlerFromInterfaces(store, &fakeAuthSvc{})
router := newRouter(h, realAuthSvc)
w := do(t, router, "POST", "/api/v1/auth/activate",
`{"token":"valid-token"}`, "")
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestCreate_AdminOnly(t *testing.T) {
store := &fakeStore{}
realAuthSvc := auth.NewService(nil, "test-secret")
h := volunteer.NewHandlerFromInterfaces(store, &fakeAuthSvc{})
router := newRouter(h, realAuthSvc)
volunteerToken := jwtForRole(t, 2, "volunteer")
w := do(t, router, "POST", "/api/v1/volunteers",
`{"name":"Bob","email":"bob@example.com"}`, volunteerToken)
if w.Code != http.StatusForbidden {
t.Fatalf("volunteer should be forbidden from creating accounts, got %d", w.Code)
}
}
func TestCreate_Admin_Success(t *testing.T) {
store := &fakeStore{}
realAuthSvc := auth.NewService(nil, "test-secret")
h := volunteer.NewHandlerFromInterfaces(store, &fakeAuthSvc{})
router := newRouter(h, realAuthSvc)
adminToken := jwtForRole(t, 1, "admin")
w := do(t, router, "POST", "/api/v1/volunteers",
`{"name":"Bob","email":"bob@example.com"}`, adminToken)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body)
}
var av volunteer.AdminVolunteer
json.NewDecoder(w.Body).Decode(&av)
if av.InviteToken == nil || *av.InviteToken == "" {
t.Error("expected invite_token in response")
}
}
func TestCreate_MissingFields(t *testing.T) {
store := &fakeStore{}
realAuthSvc := auth.NewService(nil, "test-secret")
h := volunteer.NewHandlerFromInterfaces(store, &fakeAuthSvc{})
router := newRouter(h, realAuthSvc)
adminToken := jwtForRole(t, 1, "admin")
w := do(t, router, "POST", "/api/v1/volunteers",
`{"name":"Bob"}`, adminToken)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestList_AdminGetsAdminVolunteers(t *testing.T) {
notes := "some notes"
store := &fakeStore{
adminList: []volunteer.AdminVolunteer{
{Volunteer: volunteer.Volunteer{ID: 1, Name: "Alice"}, AdminNotes: &notes},
},
}
realAuthSvc := auth.NewService(nil, "test-secret")
h := volunteer.NewHandlerFromInterfaces(store, &fakeAuthSvc{})
router := newRouter(h, realAuthSvc)
adminToken := jwtForRole(t, 1, "admin")
w := do(t, router, "GET", "/api/v1/volunteers", "", adminToken)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "some notes") {
t.Error("admin response should include admin_notes")
}
}
func TestList_VolunteerDoesNotSeeAdminNotes(t *testing.T) {
notes := "secret notes"
store := &fakeStore{
volList: []volunteer.Volunteer{{ID: 2, Name: "Bob"}},
adminList: []volunteer.AdminVolunteer{
{Volunteer: volunteer.Volunteer{ID: 2, Name: "Bob"}, AdminNotes: &notes},
},
}
realAuthSvc := auth.NewService(nil, "test-secret")
h := volunteer.NewHandlerFromInterfaces(store, &fakeAuthSvc{})
router := newRouter(h, realAuthSvc)
volunteerToken := jwtForRole(t, 2, "volunteer")
w := do(t, router, "GET", "/api/v1/volunteers", "", volunteerToken)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
if strings.Contains(w.Body.String(), "secret notes") {
t.Error("volunteer should not see admin_notes")
}
}
func TestUpdate_VolunteerCanUpdateSelf(t *testing.T) {
store := &fakeStore{
volunteer: &volunteer.Volunteer{ID: 5, Name: "Carol", Email: "carol@example.com", Active: true},
}
realAuthSvc := auth.NewService(nil, "test-secret")
h := volunteer.NewHandlerFromInterfaces(store, &fakeAuthSvc{})
router := newRouter(h, realAuthSvc)
token := jwtForRole(t, 5, "volunteer")
w := do(t, router, "PUT", "/api/v1/volunteers/5",
`{"name":"Carol Updated","phone":"555-1234"}`, token)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body)
}
}
func TestUpdate_VolunteerCannotUpdateOther(t *testing.T) {
store := &fakeStore{
volunteer: &volunteer.Volunteer{ID: 6, Name: "Dave", Active: true},
}
realAuthSvc := auth.NewService(nil, "test-secret")
h := volunteer.NewHandlerFromInterfaces(store, &fakeAuthSvc{})
router := newRouter(h, realAuthSvc)
token := jwtForRole(t, 5, "volunteer") // ID 5 trying to update ID 6
w := do(t, router, "PUT", "/api/v1/volunteers/6",
`{"name":"Hacked"}`, token)
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403, got %d", w.Code)
}
}
func TestUpdate_VolunteerCannotChangeRole(t *testing.T) {
var capturedInput volunteer.UpdateInput
store := &fakeStore{
volunteer: &volunteer.Volunteer{ID: 5, Name: "Carol", Active: true},
}
// Patch update to capture the input — use a wrapper
_ = capturedInput
realAuthSvc := auth.NewService(nil, "test-secret")
h := volunteer.NewHandlerFromInterfaces(store, &fakeAuthSvc{})
router := newRouter(h, realAuthSvc)
token := jwtForRole(t, 5, "volunteer")
// Attempt to escalate role to admin
w := do(t, router, "PUT", "/api/v1/volunteers/5",
`{"role":"admin"}`, token)
// The request should succeed (200) but the role change must be silently dropped.
// We verify by checking the response doesn't say role: admin.
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body)
}
var v volunteer.Volunteer
json.NewDecoder(w.Body).Decode(&v)
if v.Role == "admin" {
t.Error("volunteer should not be able to elevate their own role")
}
}
func TestResendInvite_AdminOnly(t *testing.T) {
store := &fakeStore{}
realAuthSvc := auth.NewService(nil, "test-secret")
h := volunteer.NewHandlerFromInterfaces(store, &fakeAuthSvc{})
router := newRouter(h, realAuthSvc)
token := jwtForRole(t, 2, "volunteer")
w := do(t, router, "POST", "/api/v1/volunteers/1/invite", `{}`, token)
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403, got %d", w.Code)
}
}
func TestResendInvite_Admin_Success(t *testing.T) {
store := &fakeStore{inviteToken: "fresh-token"}
realAuthSvc := auth.NewService(nil, "test-secret")
h := volunteer.NewHandlerFromInterfaces(store, &fakeAuthSvc{})
router := newRouter(h, realAuthSvc)
token := jwtForRole(t, 1, "admin")
w := do(t, router, "POST", "/api/v1/volunteers/1/invite", `{}`, token)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body)
}
var resp map[string]string
json.NewDecoder(w.Body).Decode(&resp)
if resp["invite_token"] != "fresh-token" {
t.Errorf("expected fresh-token, got %q", resp["invite_token"])
}
}
// Ensure fakeStore satisfies Storer at compile time.
var _ volunteer.Storer = (*fakeStore)(nil)
// Satisfy the unused time import if needed.
var _ = time.Now

View File

@@ -2,36 +2,90 @@ package volunteer
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"time"
)
var ErrNotFound = fmt.Errorf("volunteer not found")
var ErrInvalidToken = fmt.Errorf("invalid or expired invite token")
// OperationalRoles lists the valid operational role values.
var OperationalRoles = []string{
"Behaviour Team",
"Dog Log Monitor",
"Dog Shelter Volunteer",
"Trainee",
"Floater",
}
type Volunteer struct {
ID int64 `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Role string `json:"role"`
Active bool `json:"active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID int64 `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Role string `json:"role"`
Active bool `json:"active"`
IsTrainee bool `json:"is_trainee"`
Phone *string `json:"phone,omitempty"`
OperationalRoles string `json:"operational_roles"`
NotificationPreference string `json:"notification_preference"`
LastLogin *string `json:"last_login,omitempty"`
CompletedShifts int `json:"completed_shifts"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// AdminVolunteer embeds Volunteer and adds admin-only fields.
type AdminVolunteer struct {
Volunteer
AdminNotes *string `json:"admin_notes,omitempty"`
InviteToken *string `json:"invite_token,omitempty"`
}
// DisplayName returns "Name (inactive)" for deactivated volunteers.
func (v *Volunteer) DisplayName() string {
if !v.Active {
return v.Name + " (inactive)"
}
return v.Name
}
type CreateInput struct {
Name string `json:"name"`
Email string `json:"email"`
Password string `json:"password"`
Role string `json:"role"`
Name string `json:"name"`
Email string `json:"email"`
Role string `json:"role"`
IsTrainee bool `json:"is_trainee"`
Phone *string `json:"phone"`
OperationalRoles string `json:"operational_roles"`
}
type UpdateInput struct {
Name *string `json:"name"`
Email *string `json:"email"`
Role *string `json:"role"`
Active *bool `json:"active"`
Name *string `json:"name"`
Email *string `json:"email"`
Phone *string `json:"phone"`
Role *string `json:"role"`
Active *bool `json:"active"`
IsTrainee *bool `json:"is_trainee"`
OperationalRoles *string `json:"operational_roles"`
NotificationPreference *string `json:"notification_preference"`
AdminNotes *string `json:"admin_notes"`
}
// Storer is the interface the Handler depends on, enabling test fakes.
type Storer interface {
Create(ctx context.Context, in CreateInput) (*AdminVolunteer, error)
GetByID(ctx context.Context, id int64) (*Volunteer, error)
GetAdminByID(ctx context.Context, id int64) (*AdminVolunteer, error)
List(ctx context.Context, activeOnly bool) ([]Volunteer, error)
ListAdmin(ctx context.Context) ([]AdminVolunteer, error)
Update(ctx context.Context, id int64, in UpdateInput) (*Volunteer, error)
GetByInviteToken(ctx context.Context, token string) (*Volunteer, error)
Activate(ctx context.Context, token, hashedPassword string) (*Volunteer, error)
RotateInviteToken(ctx context.Context, id int64) (string, error)
RecordLogin(ctx context.Context, id int64) error
}
type Store struct {
@@ -42,41 +96,107 @@ func NewStore(db *sql.DB) *Store {
return &Store{db: db}
}
func (s *Store) Create(ctx context.Context, name, email, hashedPassword, role string) (*Volunteer, error) {
// Create inserts a new volunteer with an invite token (no password yet).
func (s *Store) Create(ctx context.Context, in CreateInput) (*AdminVolunteer, error) {
token, err := generateToken()
if err != nil {
return nil, fmt.Errorf("generate invite token: %w", err)
}
expires := time.Now().Add(72 * time.Hour)
role := in.Role
if role == "" {
role = "volunteer"
}
isTrainee := 0
if in.IsTrainee {
isTrainee = 1
}
phone := (*string)(nil)
if in.Phone != nil && *in.Phone != "" {
phone = in.Phone
}
res, err := s.db.ExecContext(ctx,
`INSERT INTO volunteers (name, email, password, role) VALUES (?, ?, ?, ?)`,
name, email, hashedPassword, role,
`INSERT INTO volunteers (name, email, role, is_trainee, phone, operational_roles, invite_token, invite_expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
in.Name, in.Email, role, isTrainee, phone, in.OperationalRoles, token, expires,
)
if err != nil {
return nil, fmt.Errorf("insert volunteer: %w", err)
}
id, _ := res.LastInsertId()
return s.GetByID(ctx, id)
return s.getAdminByID(ctx, id)
}
func (s *Store) GetByID(ctx context.Context, id int64) (*Volunteer, error) {
v := &Volunteer{}
v, err := s.getAdminByID(ctx, id)
if err != nil {
return nil, err
}
return &v.Volunteer, nil
}
func (s *Store) GetAdminByID(ctx context.Context, id int64) (*AdminVolunteer, error) {
return s.getAdminByID(ctx, id)
}
func (s *Store) getAdminByID(ctx context.Context, id int64) (*AdminVolunteer, error) {
av := &AdminVolunteer{}
v := &av.Volunteer
var createdAt, updatedAt string
err := s.db.QueryRowContext(ctx,
`SELECT id, name, email, role, active, created_at, updated_at FROM volunteers WHERE id = ?`, id,
).Scan(&v.ID, &v.Name, &v.Email, &v.Role, &v.Active, &createdAt, &updatedAt)
var lastLogin, adminNotes, inviteToken sql.NullString
var isTrainee int
err := s.db.QueryRowContext(ctx, `
SELECT v.id, v.name, v.email, v.role, v.active, v.is_trainee,
v.phone, v.operational_roles, v.notification_preference,
v.last_login, v.admin_notes, v.invite_token,
v.created_at, v.updated_at,
COUNT(c.id) AS completed_shifts
FROM volunteers v
LEFT JOIN checkins c ON c.volunteer_id = v.id AND c.checked_out_at IS NOT NULL
WHERE v.id = ?
GROUP BY v.id`, id,
).Scan(
&v.ID, &v.Name, &v.Email, &v.Role, &v.Active, &isTrainee,
&v.Phone, &v.OperationalRoles, &v.NotificationPreference,
&lastLogin, &adminNotes, &inviteToken,
&createdAt, &updatedAt, &v.CompletedShifts,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("get volunteer: %w", err)
}
v.IsTrainee = isTrainee == 1
v.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
v.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
return v, nil
if lastLogin.Valid {
av.Volunteer.LastLogin = &lastLogin.String
}
if adminNotes.Valid {
av.AdminNotes = &adminNotes.String
}
if inviteToken.Valid {
av.InviteToken = &inviteToken.String
}
return av, nil
}
func (s *Store) List(ctx context.Context, activeOnly bool) ([]Volunteer, error) {
query := `SELECT id, name, email, role, active, created_at, updated_at FROM volunteers`
query := `
SELECT v.id, v.name, v.email, v.role, v.active, v.is_trainee,
v.phone, v.operational_roles, v.notification_preference,
v.last_login, v.created_at, v.updated_at,
COUNT(c.id) AS completed_shifts
FROM volunteers v
LEFT JOIN checkins c ON c.volunteer_id = v.id AND c.checked_out_at IS NOT NULL`
if activeOnly {
query += ` WHERE active = 1`
query += ` WHERE v.active = 1`
}
query += ` ORDER BY name`
query += ` GROUP BY v.id ORDER BY v.name`
rows, err := s.db.QueryContext(ctx, query)
if err != nil {
@@ -88,43 +208,199 @@ func (s *Store) List(ctx context.Context, activeOnly bool) ([]Volunteer, error)
for rows.Next() {
var v Volunteer
var createdAt, updatedAt string
if err := rows.Scan(&v.ID, &v.Name, &v.Email, &v.Role, &v.Active, &createdAt, &updatedAt); err != nil {
var lastLogin sql.NullString
var isTrainee int
if err := rows.Scan(
&v.ID, &v.Name, &v.Email, &v.Role, &v.Active, &isTrainee,
&v.Phone, &v.OperationalRoles, &v.NotificationPreference,
&lastLogin, &createdAt, &updatedAt, &v.CompletedShifts,
); err != nil {
return nil, err
}
v.IsTrainee = isTrainee == 1
v.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
v.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
if lastLogin.Valid {
v.LastLogin = &lastLogin.String
}
volunteers = append(volunteers, v)
}
return volunteers, rows.Err()
}
// ListAdmin returns all volunteers (including inactive) with admin-only fields.
func (s *Store) ListAdmin(ctx context.Context) ([]AdminVolunteer, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT v.id, v.name, v.email, v.role, v.active, v.is_trainee,
v.phone, v.operational_roles, v.notification_preference,
v.last_login, v.admin_notes, v.invite_token,
v.created_at, v.updated_at,
COUNT(c.id) AS completed_shifts
FROM volunteers v
LEFT JOIN checkins c ON c.volunteer_id = v.id AND c.checked_out_at IS NOT NULL
GROUP BY v.id ORDER BY v.name`)
if err != nil {
return nil, fmt.Errorf("list volunteers (admin): %w", err)
}
defer rows.Close()
var volunteers []AdminVolunteer
for rows.Next() {
var av AdminVolunteer
v := &av.Volunteer
var createdAt, updatedAt string
var lastLogin, adminNotes, inviteToken sql.NullString
var isTrainee int
if err := rows.Scan(
&v.ID, &v.Name, &v.Email, &v.Role, &v.Active, &isTrainee,
&v.Phone, &v.OperationalRoles, &v.NotificationPreference,
&lastLogin, &adminNotes, &inviteToken,
&createdAt, &updatedAt, &v.CompletedShifts,
); err != nil {
return nil, err
}
v.IsTrainee = isTrainee == 1
v.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
v.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
if lastLogin.Valid {
v.LastLogin = &lastLogin.String
}
if adminNotes.Valid {
av.AdminNotes = &adminNotes.String
}
if inviteToken.Valid {
av.InviteToken = &inviteToken.String
}
volunteers = append(volunteers, av)
}
return volunteers, rows.Err()
}
func (s *Store) Update(ctx context.Context, id int64, in UpdateInput) (*Volunteer, error) {
v, err := s.GetByID(ctx, id)
av, err := s.getAdminByID(ctx, id)
if err != nil {
return nil, err
}
v := &av.Volunteer
if in.Name != nil {
v.Name = *in.Name
}
if in.Email != nil {
v.Email = *in.Email
}
if in.Phone != nil {
v.Phone = in.Phone
}
if in.Role != nil {
v.Role = *in.Role
}
if in.Active != nil {
v.Active = *in.Active
}
if in.IsTrainee != nil {
v.IsTrainee = *in.IsTrainee
}
if in.OperationalRoles != nil {
v.OperationalRoles = *in.OperationalRoles
}
if in.NotificationPreference != nil {
v.NotificationPreference = *in.NotificationPreference
}
if in.AdminNotes != nil {
av.AdminNotes = in.AdminNotes
}
activeInt := 0
if v.Active {
activeInt = 1
}
isTraineeInt := 0
if v.IsTrainee {
isTraineeInt = 1
}
_, err = s.db.ExecContext(ctx,
`UPDATE volunteers SET name=?, email=?, role=?, active=?, updated_at=NOW() WHERE id=?`,
v.Name, v.Email, v.Role, activeInt, id,
`UPDATE volunteers SET name=?, email=?, phone=?, role=?, active=?, is_trainee=?,
operational_roles=?, notification_preference=?, admin_notes=?, updated_at=NOW() WHERE id=?`,
v.Name, v.Email, v.Phone, v.Role, activeInt, isTraineeInt,
v.OperationalRoles, v.NotificationPreference, av.AdminNotes, id,
)
if err != nil {
return nil, fmt.Errorf("update volunteer: %w", err)
}
return s.GetByID(ctx, id)
}
// GetByInviteToken returns a volunteer by their invite token if it's still valid.
func (s *Store) GetByInviteToken(ctx context.Context, token string) (*Volunteer, error) {
var id int64
err := s.db.QueryRowContext(ctx,
`SELECT id FROM volunteers WHERE invite_token = ? AND invite_expires_at > NOW() AND active = 1`,
token,
).Scan(&id)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrInvalidToken
}
if err != nil {
return nil, fmt.Errorf("lookup invite token: %w", err)
}
return s.GetByID(ctx, id)
}
// Activate sets a volunteer's password and clears the invite token.
func (s *Store) Activate(ctx context.Context, token, hashedPassword string) (*Volunteer, error) {
// Look up the ID first while the token is still valid.
var id int64
err := s.db.QueryRowContext(ctx,
`SELECT id FROM volunteers WHERE invite_token = ? AND invite_expires_at > NOW()`,
token,
).Scan(&id)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrInvalidToken
}
if err != nil {
return nil, fmt.Errorf("lookup invite token: %w", err)
}
_, err = s.db.ExecContext(ctx,
`UPDATE volunteers SET password=?, invite_token=NULL, invite_expires_at=NULL, updated_at=NOW()
WHERE id=?`,
hashedPassword, id,
)
if err != nil {
return nil, fmt.Errorf("activate account: %w", err)
}
return s.GetByID(ctx, id)
}
// RotateInviteToken generates a new invite token for a volunteer.
func (s *Store) RotateInviteToken(ctx context.Context, id int64) (string, error) {
token, err := generateToken()
if err != nil {
return "", fmt.Errorf("generate token: %w", err)
}
expires := time.Now().Add(72 * time.Hour)
_, err = s.db.ExecContext(ctx,
`UPDATE volunteers SET invite_token=?, invite_expires_at=?, updated_at=NOW() WHERE id=?`,
token, expires, id,
)
if err != nil {
return "", fmt.Errorf("rotate invite token: %w", err)
}
return token, nil
}
// RecordLogin updates last_login for a volunteer.
func (s *Store) RecordLogin(ctx context.Context, id int64) error {
_, err := s.db.ExecContext(ctx,
`UPDATE volunteers SET last_login=NOW(), updated_at=NOW() WHERE id=?`, id,
)
return err
}
func generateToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}

View File

@@ -31,6 +31,13 @@
"react-app/jest"
]
},
"jest": {
"moduleNameMapper": {
"^react-router-dom$": "<rootDir>/node_modules/react-router-dom/dist/index.js",
"^react-router$": "<rootDir>/node_modules/react-router/dist/development/index.js",
"^react-router/dom$": "<rootDir>/node_modules/react-router/dist/development/dom-export.js"
}
},
"browserslist": {
"production": [
">0.2%",

View File

@@ -2,8 +2,28 @@ import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
// Mock all API calls so the app renders without a backend
jest.mock('./api', () => ({
api: {
listVolunteers: jest.fn().mockResolvedValue([]),
listSchedules: jest.fn().mockResolvedValue([]),
listTimeOff: jest.fn().mockResolvedValue([]),
listNotifications: jest.fn().mockResolvedValue([]),
getVolunteer: jest.fn().mockResolvedValue(null),
},
OPERATIONAL_ROLES: [],
}));
test('renders login page when unauthenticated', () => {
localStorage.clear();
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /sign in/i })).toBeInTheDocument();
});
test('login page has email and password fields', () => {
localStorage.clear();
render(<App />);
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument();
});

View File

@@ -2,10 +2,12 @@ import React from 'react';
import { BrowserRouter, Routes, Route, NavLink, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from './auth';
import Login from './pages/Login';
import Activate from './pages/Activate';
import Dashboard from './pages/Dashboard';
import Schedules from './pages/Schedules';
import TimeOff from './pages/TimeOff';
import Volunteers from './pages/Volunteers';
import Profile from './pages/Profile';
import './App.css';
function Nav() {
@@ -16,6 +18,7 @@ function Nav() {
<NavLink to="/">Dashboard</NavLink>
<NavLink to="/schedules">Schedules</NavLink>
<NavLink to="/timeoff">Time Off</NavLink>
<NavLink to="/profile">Profile</NavLink>
{role === 'admin' && <NavLink to="/volunteers">Volunteers</NavLink>}
<button className="btn-link" onClick={logout}>Sign Out</button>
</nav>
@@ -33,6 +36,7 @@ function ProtectedLayout() {
<Route path="/" element={<Dashboard />} />
<Route path="/schedules" element={<Schedules />} />
<Route path="/timeoff" element={<TimeOff />} />
<Route path="/profile" element={<Profile />} />
<Route path="/volunteers" element={<Volunteers />} />
</Routes>
</main>
@@ -52,6 +56,7 @@ export default function App() {
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginRoute />} />
<Route path="/activate" element={<Activate />} />
<Route path="/*" element={<ProtectedLayout />} />
</Routes>
</BrowserRouter>

View File

@@ -25,14 +25,18 @@ export const api = {
// Auth
login: (email: string, password: string) =>
request<{ token: string }>('POST', '/auth/login', { email, password }),
register: (name: string, email: string, password: string, role = 'volunteer') =>
request<Volunteer>('POST', '/auth/register', { name, email, password, role }),
activate: (token: string, password: string) =>
request<Volunteer>('POST', '/auth/activate', { token, password }),
// Volunteers
listVolunteers: () => request<Volunteer[]>('GET', '/volunteers'),
getVolunteer: (id: number) => request<Volunteer>('GET', `/volunteers/${id}`),
updateVolunteer: (id: number, data: Partial<Volunteer>) =>
createVolunteer: (data: CreateVolunteerInput) =>
request<AdminVolunteer>('POST', '/volunteers', data),
listVolunteers: () => request<Volunteer[] | AdminVolunteer[]>('GET', '/volunteers'),
getVolunteer: (id: number) => request<Volunteer | AdminVolunteer>('GET', `/volunteers/${id}`),
updateVolunteer: (id: number, data: Partial<UpdateVolunteerInput>) =>
request<Volunteer>('PUT', `/volunteers/${id}`, data),
resendInvite: (id: number) =>
request<{ invite_token: string }>('POST', `/volunteers/${id}/invite`, {}),
// Schedules
listSchedules: () => request<Schedule[]>('GET', '/schedules'),
@@ -64,10 +68,42 @@ export interface Volunteer {
email: string;
role: 'admin' | 'volunteer';
active: boolean;
is_trainee: boolean;
phone?: string;
operational_roles: string;
notification_preference: string;
last_login?: string;
completed_shifts: number;
created_at: string;
updated_at: string;
}
export interface AdminVolunteer extends Volunteer {
admin_notes?: string;
invite_token?: string;
}
export interface CreateVolunteerInput {
name: string;
email: string;
role?: 'admin' | 'volunteer';
is_trainee?: boolean;
phone?: string;
operational_roles?: string;
}
export interface UpdateVolunteerInput {
name?: string;
email?: string;
phone?: string;
role?: 'admin' | 'volunteer';
active?: boolean;
is_trainee?: boolean;
operational_roles?: string;
notification_preference?: string;
admin_notes?: string;
}
export interface Schedule {
id: number;
volunteer_id: number;
@@ -122,3 +158,11 @@ export interface Notification {
read: boolean;
created_at: string;
}
export const OPERATIONAL_ROLES = [
'Behaviour Team',
'Dog Log Monitor',
'Dog Shelter Volunteer',
'Trainee',
'Floater',
] as const;

View File

@@ -0,0 +1,92 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import Activate from './Activate';
import { api } from '../api';
import { AuthProvider } from '../auth';
jest.mock('../api', () => ({
api: {
activate: jest.fn(),
},
}));
const mockActivate = api.activate as jest.Mock;
function renderActivate(token = 'valid-token') {
return render(
<AuthProvider>
<MemoryRouter initialEntries={[`/activate?token=${token}`]}>
<Routes>
<Route path="/activate" element={<Activate />} />
<Route path="/login" element={<div>Login Page</div>} />
</Routes>
</MemoryRouter>
</AuthProvider>,
);
}
beforeEach(() => {
mockActivate.mockReset();
});
test('renders password fields', () => {
renderActivate();
expect(screen.getByLabelText(/^password$/i)).toBeInTheDocument();
expect(screen.getByLabelText(/confirm password/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /activate account/i })).toBeInTheDocument();
});
test('shows error when no token in URL', () => {
render(
<AuthProvider>
<MemoryRouter initialEntries={['/activate']}>
<Routes>
<Route path="/activate" element={<Activate />} />
</Routes>
</MemoryRouter>
</AuthProvider>,
);
expect(screen.getByText(/no invite token/i)).toBeInTheDocument();
});
test('shows error when passwords do not match', async () => {
renderActivate();
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: /activate account/i }));
expect(await screen.findByText(/passwords do not match/i)).toBeInTheDocument();
});
test('shows error when password too short', async () => {
renderActivate();
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: /activate account/i }));
expect(await screen.findByText(/at least 8 characters/i)).toBeInTheDocument();
});
test('calls api.activate and navigates to login on success', async () => {
mockActivate.mockResolvedValueOnce({ id: 1, name: 'Alice' });
renderActivate('valid-token');
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: /activate account/i }));
await waitFor(() => {
expect(mockActivate).toHaveBeenCalledWith('valid-token', 'goodpassword');
});
expect(await screen.findByText(/login page/i)).toBeInTheDocument();
});
test('shows error when api.activate fails', async () => {
mockActivate.mockRejectedValueOnce(new Error('invalid or expired invite token'));
renderActivate('bad-token');
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: /activate account/i }));
expect(await screen.findByText(/invalid or expired invite token/i)).toBeInTheDocument();
});

View File

@@ -0,0 +1,75 @@
import React, { useState, FormEvent, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { api } from '../api';
import { useAuth } from '../auth';
export default function Activate() {
const [searchParams] = useSearchParams();
const { login } = useAuth();
const navigate = useNavigate();
const [password, setPassword] = useState('');
const [confirm, setConfirm] = useState('');
const [error, setError] = useState('');
const [submitting, setSubmitting] = useState(false);
const token = searchParams.get('token') ?? '';
useEffect(() => {
if (!token) setError('No invite token provided. Check your invite link.');
}, [token]);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError('');
if (password !== confirm) {
setError('Passwords do not match.');
return;
}
if (password.length < 8) {
setError('Password must be at least 8 characters.');
return;
}
setSubmitting(true);
try {
await api.activate(token, password);
// After activation, prompt user to log in with their new password.
navigate('/login?activated=1');
} catch (err: any) {
setError(err.message);
} finally {
setSubmitting(false);
}
}
return (
<div className="auth-page">
<h1>Walkies</h1>
<h2>Set Your Password</h2>
<p>Welcome! Set a password to activate your account.</p>
<form onSubmit={handleSubmit}>
{error && <p className="error">{error}</p>}
<label>
Password
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
required
minLength={8}
/>
</label>
<label>
Confirm password
<input
type="password"
value={confirm}
onChange={e => setConfirm(e.target.value)}
required
/>
</label>
<button type="submit" disabled={submitting || !token}>
{submitting ? 'Activating…' : 'Activate Account'}
</button>
</form>
</div>
);
}

View File

@@ -1,14 +1,16 @@
import React, { useState, FormEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { api } from '../api';
import { useAuth } from '../auth';
export default function Login() {
const { login } = useAuth();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const activated = searchParams.get('activated') === '1';
async function handleSubmit(e: FormEvent) {
e.preventDefault();
@@ -27,6 +29,7 @@ export default function Login() {
<h1>Walkies</h1>
<h2>Sign In</h2>
<form onSubmit={handleSubmit}>
{activated && <p className="success">Account activated! Sign in with your new password.</p>}
{error && <p className="error">{error}</p>}
<label>
Email

View File

@@ -0,0 +1,111 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import Profile from './Profile';
import { api, Volunteer } from '../api';
import { AuthProvider } from '../auth';
jest.mock('../api', () => ({
api: {
getVolunteer: jest.fn(),
updateVolunteer: jest.fn(),
},
}));
// Provide a pre-decoded token so AuthProvider exposes volunteerID = 5
const FAKE_TOKEN = buildFakeJWT({ volunteer_id: 5, role: 'volunteer', exp: 9999999999 });
function buildFakeJWT(payload: object): string {
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
const body = btoa(JSON.stringify(payload));
return `${header}.${body}.fakesig`;
}
const mockGetVolunteer = api.getVolunteer as jest.Mock;
const mockUpdateVolunteer = api.updateVolunteer as jest.Mock;
const baseVolunteer: Volunteer = {
id: 5,
name: 'Carol',
email: 'carol@example.com',
role: 'volunteer',
active: true,
is_trainee: false,
phone: '555-0100',
operational_roles: 'Floater',
notification_preference: 'email',
completed_shifts: 3,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
};
function renderProfile() {
localStorage.setItem('token', FAKE_TOKEN);
return render(
<AuthProvider>
<MemoryRouter>
<Profile />
</MemoryRouter>
</AuthProvider>,
);
}
beforeEach(() => {
mockGetVolunteer.mockReset();
mockUpdateVolunteer.mockReset();
localStorage.clear();
});
test('renders volunteer name and email from API', async () => {
mockGetVolunteer.mockResolvedValueOnce(baseVolunteer);
renderProfile();
expect(await screen.findByDisplayValue('Carol')).toBeInTheDocument();
expect(screen.getByDisplayValue('carol@example.com')).toBeInTheDocument();
});
test('email field is read-only', async () => {
mockGetVolunteer.mockResolvedValueOnce(baseVolunteer);
renderProfile();
await screen.findByDisplayValue('Carol');
expect(screen.getByDisplayValue('carol@example.com')).toBeDisabled();
});
test('submits updated name and phone', async () => {
mockGetVolunteer.mockResolvedValueOnce(baseVolunteer);
mockUpdateVolunteer.mockResolvedValueOnce({ ...baseVolunteer, name: 'Carol Updated' });
renderProfile();
const nameInput = await screen.findByDisplayValue('Carol');
fireEvent.change(nameInput, { target: { value: 'Carol Updated' } });
fireEvent.click(screen.getByRole('button', { name: /save changes/i }));
await waitFor(() => {
expect(mockUpdateVolunteer).toHaveBeenCalledWith(5, expect.objectContaining({
name: 'Carol Updated',
}));
});
expect(await screen.findByText(/profile updated/i)).toBeInTheDocument();
});
test('shows error on save failure', async () => {
mockGetVolunteer.mockResolvedValueOnce(baseVolunteer);
mockUpdateVolunteer.mockRejectedValueOnce(new Error('Server error'));
renderProfile();
await screen.findByDisplayValue('Carol');
fireEvent.click(screen.getByRole('button', { name: /save changes/i }));
expect(await screen.findByText(/server error/i)).toBeInTheDocument();
});
test('shows completed shift count', async () => {
mockGetVolunteer.mockResolvedValueOnce(baseVolunteer);
renderProfile();
expect(await screen.findByText(/completed shifts: 3/i)).toBeInTheDocument();
});
test('shows trainee badge when is_trainee is true', async () => {
mockGetVolunteer.mockResolvedValueOnce({ ...baseVolunteer, is_trainee: true });
renderProfile();
expect(await screen.findByText(/trainee/i)).toBeInTheDocument();
});

77
web/src/pages/Profile.tsx Normal file
View File

@@ -0,0 +1,77 @@
import React, { useEffect, useState, FormEvent } from 'react';
import { api, Volunteer } from '../api';
import { useAuth } from '../auth';
export default function Profile() {
const { volunteerID } = useAuth();
const [volunteer, setVolunteer] = useState<Volunteer | null>(null);
const [name, setName] = useState('');
const [phone, setPhone] = useState('');
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!volunteerID) return;
api.getVolunteer(volunteerID).then(v => {
const vol = v as Volunteer;
setVolunteer(vol);
setName(vol.name);
setPhone(vol.phone ?? '');
}).catch(() => setError('Could not load profile.'));
}, [volunteerID]);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError('');
setSuccess('');
if (!volunteerID) return;
setSaving(true);
try {
const updated = await api.updateVolunteer(volunteerID, { name, phone: phone || undefined });
setVolunteer(updated);
setSuccess('Profile updated.');
} catch (err: any) {
setError(err.message);
} finally {
setSaving(false);
}
}
if (!volunteer) return <div className="page"><p>Loading</p></div>;
return (
<div className="page">
<h2>My Profile</h2>
{error && <p className="error">{error}</p>}
{success && <p className="success">{success}</p>}
<form onSubmit={handleSubmit} style={{ maxWidth: 400 }}>
<label>
Full name
<input value={name} onChange={e => setName(e.target.value)} required />
</label>
<label>
Email
<input value={volunteer.email} disabled />
</label>
<label>
Phone
<input value={phone} onChange={e => setPhone(e.target.value)} placeholder="Optional" />
</label>
<label>
Operational roles
<input value={volunteer.operational_roles || '—'} disabled />
</label>
<label>
Notification preference
<input value={volunteer.notification_preference} disabled />
</label>
<p style={{ color: '#666', fontSize: '0.85em' }}>
Completed shifts: {volunteer.completed_shifts}
{volunteer.is_trainee && ' · Trainee'}
</p>
<button type="submit" disabled={saving}>{saving ? 'Saving…' : 'Save Changes'}</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,171 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import Volunteers from './Volunteers';
import { api, AdminVolunteer } from '../api';
import { AuthProvider } from '../auth';
jest.mock('../api', () => ({
api: {
listVolunteers: jest.fn(),
createVolunteer: jest.fn(),
updateVolunteer: jest.fn(),
resendInvite: jest.fn(),
},
OPERATIONAL_ROLES: ['Behaviour Team', 'Dog Log Monitor', 'Dog Shelter Volunteer', 'Trainee', 'Floater'],
}));
const mockListVolunteers = api.listVolunteers as jest.Mock;
const mockCreateVolunteer = api.createVolunteer as jest.Mock;
const mockUpdateVolunteer = api.updateVolunteer as jest.Mock;
const mockResendInvite = api.resendInvite as jest.Mock;
function buildFakeJWT(payload: object): string {
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
const body = btoa(JSON.stringify(payload));
return `${header}.${body}.fakesig`;
}
const ADMIN_TOKEN = buildFakeJWT({ volunteer_id: 1, role: 'admin', exp: 9999999999 });
const alice: AdminVolunteer = {
id: 1,
name: 'Alice',
email: 'alice@example.com',
role: 'volunteer',
active: true,
is_trainee: false,
operational_roles: 'Floater',
notification_preference: 'email',
completed_shifts: 5,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
};
const trainee: AdminVolunteer = {
...alice,
id: 2,
name: 'Bob',
email: 'bob@example.com',
is_trainee: true,
completed_shifts: 1,
};
function renderVolunteers() {
localStorage.setItem('token', ADMIN_TOKEN);
return render(
<AuthProvider>
<MemoryRouter>
<Volunteers />
</MemoryRouter>
</AuthProvider>,
);
}
beforeEach(() => {
mockListVolunteers.mockReset();
mockCreateVolunteer.mockReset();
mockUpdateVolunteer.mockReset();
mockResendInvite.mockReset();
localStorage.clear();
});
test('renders volunteer list', async () => {
mockListVolunteers.mockResolvedValueOnce([alice]);
renderVolunteers();
expect(await screen.findByText('Alice')).toBeInTheDocument();
expect(screen.getByText('alice@example.com')).toBeInTheDocument();
});
test('shows trainee badge for trainee volunteers', async () => {
mockListVolunteers.mockResolvedValueOnce([trainee]);
renderVolunteers();
await screen.findByText('Bob');
expect(screen.getByText('Trainee')).toBeInTheDocument();
});
test('shows promote button with shift count for trainees', async () => {
mockListVolunteers.mockResolvedValueOnce([trainee]);
renderVolunteers();
await screen.findByText('Bob');
expect(screen.getByRole('button', { name: /promote.*1 shift/i })).toBeInTheDocument();
});
test('shows add volunteer button', async () => {
mockListVolunteers.mockResolvedValueOnce([]);
renderVolunteers();
expect(await screen.findByRole('button', { name: /add volunteer/i })).toBeInTheDocument();
});
test('create form appears on Add Volunteer click', async () => {
mockListVolunteers.mockResolvedValueOnce([]);
renderVolunteers();
fireEvent.click(await screen.findByRole('button', { name: /add volunteer/i }));
expect(screen.getByRole('heading', { name: /new volunteer/i })).toBeInTheDocument();
expect(screen.getByLabelText(/name/i)).toBeInTheDocument();
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
});
test('creates volunteer and shows invite link', async () => {
mockListVolunteers.mockResolvedValueOnce([]);
const inviteToken = 'abc123invite';
mockCreateVolunteer.mockResolvedValueOnce({
...alice,
id: 10,
name: 'New Person',
email: 'new@example.com',
invite_token: inviteToken,
});
renderVolunteers();
fireEvent.click(await screen.findByRole('button', { name: /add volunteer/i }));
fireEvent.change(screen.getByLabelText(/^name/i), { target: { value: 'New Person' } });
fireEvent.change(screen.getByLabelText(/^email/i), { target: { value: 'new@example.com' } });
fireEvent.click(screen.getByRole('button', { name: /create.*invite/i }));
await waitFor(() => expect(mockCreateVolunteer).toHaveBeenCalled());
expect(await screen.findByText(new RegExp(inviteToken))).toBeInTheDocument();
});
test('deactivates volunteer on Deactivate click', async () => {
mockListVolunteers.mockResolvedValueOnce([alice]);
mockUpdateVolunteer.mockResolvedValueOnce({ ...alice, active: false });
renderVolunteers();
fireEvent.click(await screen.findByRole('button', { name: /deactivate/i }));
await waitFor(() =>
expect(mockUpdateVolunteer).toHaveBeenCalledWith(alice.id, { active: false }),
);
});
test('promotes trainee to volunteer', async () => {
mockListVolunteers.mockResolvedValueOnce([trainee]);
mockUpdateVolunteer.mockResolvedValueOnce({ ...trainee, is_trainee: false });
renderVolunteers();
await screen.findByText('Bob');
fireEvent.click(screen.getByRole('button', { name: /promote/i }));
await waitFor(() =>
expect(mockUpdateVolunteer).toHaveBeenCalledWith(trainee.id, { is_trainee: false }),
);
});
test('shows error when list fails', async () => {
mockListVolunteers.mockRejectedValueOnce(new Error('Network error'));
renderVolunteers();
expect(await screen.findByText(/could not load volunteers/i)).toBeInTheDocument();
});
test('resend invite shows new link', async () => {
const volWithToken: AdminVolunteer = { ...alice, invite_token: 'old-token' };
mockListVolunteers.mockResolvedValueOnce([volWithToken]);
mockResendInvite.mockResolvedValueOnce({ invite_token: 'new-fresh-token' });
renderVolunteers();
await screen.findByText('Alice');
fireEvent.click(screen.getByRole('button', { name: /resend invite/i }));
expect(await screen.findByText(/new-fresh-token/i)).toBeInTheDocument();
});

View File

@@ -1,27 +1,172 @@
import React, { useEffect, useState } from 'react';
import { api, Volunteer } from '../api';
import { api, AdminVolunteer, CreateVolunteerInput, OPERATIONAL_ROLES } from '../api';
const EMPTY_FORM: CreateVolunteerInput = {
name: '',
email: '',
role: 'volunteer',
is_trainee: false,
phone: '',
operational_roles: '',
};
export default function Volunteers() {
const [volunteers, setVolunteers] = useState<Volunteer[]>([]);
const [volunteers, setVolunteers] = useState<AdminVolunteer[]>([]);
const [error, setError] = useState('');
const [showCreate, setShowCreate] = useState(false);
const [form, setForm] = useState<CreateVolunteerInput>(EMPTY_FORM);
const [creating, setCreating] = useState(false);
const [inviteLink, setInviteLink] = useState<{ name: string; token: string } | null>(null);
const [expandedId, setExpandedId] = useState<number | null>(null);
const [editNotes, setEditNotes] = useState<{ id: number; notes: string } | null>(null);
useEffect(() => {
api.listVolunteers().then(setVolunteers).catch(() => setError('Could not load volunteers.'));
load();
}, []);
async function handleToggleActive(v: Volunteer) {
function load() {
api.listVolunteers()
.then(vs => setVolunteers(vs as AdminVolunteer[]))
.catch(() => setError('Could not load volunteers.'));
}
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
setCreating(true);
try {
const av = await api.createVolunteer(form);
setVolunteers(prev => [...prev, av]);
setShowCreate(false);
setForm(EMPTY_FORM);
if (av.invite_token) {
setInviteLink({ name: av.name, token: av.invite_token });
}
} catch (err: any) {
setError(err.message);
} finally {
setCreating(false);
}
}
async function handleToggleActive(v: AdminVolunteer) {
try {
const updated = await api.updateVolunteer(v.id, { active: !v.active });
setVolunteers(prev => prev.map(vol => vol.id === v.id ? updated : vol));
setVolunteers(prev => prev.map(vol => vol.id === v.id ? { ...vol, ...updated } : vol));
} catch (err: any) {
setError(err.message);
}
}
async function handlePromoteTrainee(v: AdminVolunteer) {
try {
const updated = await api.updateVolunteer(v.id, { is_trainee: false });
setVolunteers(prev => prev.map(vol => vol.id === v.id ? { ...vol, ...updated } : vol));
} catch (err: any) {
setError(err.message);
}
}
async function handleSaveNotes(id: number, notes: string) {
try {
await api.updateVolunteer(id, { admin_notes: notes });
setVolunteers(prev => prev.map(v => v.id === id ? { ...v, admin_notes: notes } : v));
setEditNotes(null);
} catch (err: any) {
setError(err.message);
}
}
async function handleResendInvite(v: AdminVolunteer) {
try {
const { invite_token } = await api.resendInvite(v.id);
setInviteLink({ name: v.name, token: invite_token });
} catch (err: any) {
setError(err.message);
}
}
function toggleOpsRole(role: string) {
const current = form.operational_roles ? form.operational_roles.split(',').filter(Boolean) : [];
const next = current.includes(role)
? current.filter(r => r !== role)
: [...current, role];
setForm(f => ({ ...f, operational_roles: next.join(',') }));
}
const inviteUrl = inviteLink
? `${window.location.origin}/activate?token=${inviteLink.token}`
: '';
return (
<div className="page">
<h2>Volunteers</h2>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2>Volunteers</h2>
<button onClick={() => setShowCreate(s => !s)}>
{showCreate ? 'Cancel' : '+ Add Volunteer'}
</button>
</div>
{error && <p className="error">{error}</p>}
{inviteLink && (
<div className="notice">
<strong>Invite link for {inviteLink.name}:</strong>
<br />
<code style={{ wordBreak: 'break-all' }}>{inviteUrl}</code>
<br />
<button className="btn-small" onClick={() => { navigator.clipboard.writeText(inviteUrl); }}>
Copy
</button>
<button className="btn-small" style={{ marginLeft: 8 }} onClick={() => setInviteLink(null)}>
Dismiss
</button>
</div>
)}
{showCreate && (
<form className="card" onSubmit={handleCreate}>
<h3>New Volunteer</h3>
<label>
Name *
<input value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} required />
</label>
<label>
Email *
<input type="email" value={form.email} onChange={e => setForm(f => ({ ...f, email: e.target.value }))} required />
</label>
<label>
Phone
<input value={form.phone ?? ''} onChange={e => setForm(f => ({ ...f, phone: e.target.value }))} />
</label>
<label>
Account type
<select value={form.role} onChange={e => setForm(f => ({ ...f, role: e.target.value as 'admin' | 'volunteer' }))}>
<option value="volunteer">Volunteer</option>
<option value="admin">Admin</option>
</select>
</label>
<label>
<input type="checkbox" checked={form.is_trainee ?? false}
onChange={e => setForm(f => ({ ...f, is_trainee: e.target.checked }))} />
{' '}Trainee (blocks open shift claiming)
</label>
<fieldset>
<legend>Operational roles</legend>
{OPERATIONAL_ROLES.map(r => (
<label key={r} style={{ display: 'block' }}>
<input
type="checkbox"
checked={(form.operational_roles ?? '').split(',').includes(r)}
onChange={() => toggleOpsRole(r)}
/>
{' '}{r}
</label>
))}
</fieldset>
<button type="submit" disabled={creating}>{creating ? 'Creating…' : 'Create & Send Invite'}</button>
</form>
)}
{volunteers.length === 0 ? (
<p>No volunteers found.</p>
) : (
@@ -31,23 +176,74 @@ export default function Volunteers() {
<th>Name</th>
<th>Email</th>
<th>Role</th>
<th>Op. Roles</th>
<th>Shifts</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{volunteers.map(v => (
<tr key={v.id}>
<td>{v.name}</td>
<td>{v.email}</td>
<td>{v.role}</td>
<td>{v.active ? 'Active' : 'Inactive'}</td>
<td>
<button className="btn-small" onClick={() => handleToggleActive(v)}>
{v.active ? 'Deactivate' : 'Activate'}
</button>
</td>
</tr>
<React.Fragment key={v.id}>
<tr>
<td>
{v.active ? v.name : `${v.name} (inactive)`}
{v.is_trainee && <span className="badge">Trainee</span>}
</td>
<td>{v.email}</td>
<td>{v.role}</td>
<td>{v.operational_roles || '—'}</td>
<td>{v.completed_shifts}</td>
<td>{v.active ? 'Active' : 'Inactive'}</td>
<td>
<button className="btn-small" onClick={() => handleToggleActive(v)}>
{v.active ? 'Deactivate' : 'Activate'}
</button>
{v.is_trainee && (
<button className="btn-small" style={{ marginLeft: 4 }} onClick={() => handlePromoteTrainee(v)}>
Promote ({v.completed_shifts} shifts)
</button>
)}
{v.invite_token && (
<button className="btn-small" style={{ marginLeft: 4 }} onClick={() => handleResendInvite(v)}>
Resend Invite
</button>
)}
<button className="btn-small" style={{ marginLeft: 4 }}
onClick={() => setExpandedId(expandedId === v.id ? null : v.id)}>
{expandedId === v.id ? 'Hide Notes' : 'Notes'}
</button>
</td>
</tr>
{expandedId === v.id && (
<tr>
<td colSpan={7}>
{editNotes?.id === v.id ? (
<div>
<textarea
rows={3}
style={{ width: '100%' }}
value={editNotes.notes}
onChange={e => setEditNotes({ id: v.id, notes: e.target.value })}
/>
<button className="btn-small" onClick={() => handleSaveNotes(v.id, editNotes.notes)}>Save</button>
<button className="btn-small" style={{ marginLeft: 4 }} onClick={() => setEditNotes(null)}>Cancel</button>
</div>
) : (
<div>
<em style={{ color: '#666' }}>Admin notes:</em>{' '}
{v.admin_notes || <span style={{ color: '#999' }}>None</span>}
<button className="btn-small" style={{ marginLeft: 8 }}
onClick={() => setEditNotes({ id: v.id, notes: v.admin_notes ?? '' })}>
Edit
</button>
</div>
)}
{v.last_login && <div style={{ marginTop: 4, color: '#666', fontSize: '0.85em' }}>Last login: {v.last_login}</div>}
</td>
</tr>
)}
</React.Fragment>
))}
</tbody>
</table>

View File

@@ -3,3 +3,7 @@
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';
// React Router v7 requires TextEncoder/TextDecoder which jsdom (CRA 5) does not provide.
import { TextEncoder, TextDecoder } from 'util';
Object.assign(global, { TextEncoder, TextDecoder });