Implement Issue #1: User Accounts & Profiles
- 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:
49
.gitea/workflows/ci.yml
Normal file
49
.gitea/workflows/ci.yml
Normal 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
1
.node-version
Normal file
@@ -0,0 +1 @@
|
||||
22
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
92
internal/auth/auth_test.go
Normal file
92
internal/auth/auth_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
442
internal/volunteer/handler_test.go
Normal file
442
internal/volunteer/handler_test.go
Normal 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: ¬es},
|
||||
},
|
||||
}
|
||||
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: ¬es},
|
||||
},
|
||||
}
|
||||
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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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%",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
92
web/src/pages/Activate.test.tsx
Normal file
92
web/src/pages/Activate.test.tsx
Normal 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();
|
||||
});
|
||||
75
web/src/pages/Activate.tsx
Normal file
75
web/src/pages/Activate.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
111
web/src/pages/Profile.test.tsx
Normal file
111
web/src/pages/Profile.test.tsx
Normal 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
77
web/src/pages/Profile.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
171
web/src/pages/Volunteers.test.tsx
Normal file
171
web/src/pages/Volunteers.test.tsx
Normal 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();
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user