- 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>
222 lines
6.4 KiB
Go
222 lines
6.4 KiB
Go
package volunteer
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"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 Storer
|
|
authSvc AuthServicer
|
|
}
|
|
|
|
func NewHandler(store *Store, authSvc *auth.Service) *Handler {
|
|
return &Handler{store: store, authSvc: authSvc}
|
|
}
|
|
|
|
// NewHandlerFromInterfaces constructs a Handler from interface values, intended for testing.
|
|
func NewHandlerFromInterfaces(store Storer, authSvc AuthServicer) *Handler {
|
|
return &Handler{store: store, authSvc: authSvc}
|
|
}
|
|
|
|
// POST /api/v1/auth/login
|
|
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
|
var body struct {
|
|
Email string `json:"email"`
|
|
Password string `json:"password"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
respond.Error(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
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")
|
|
return
|
|
}
|
|
if volunteers == nil {
|
|
volunteers = []Volunteer{}
|
|
}
|
|
respond.JSON(w, http.StatusOK, volunteers)
|
|
}
|
|
|
|
// GET /api/v1/volunteers/{id}
|
|
func (h *Handler) Get(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 && 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")
|
|
return
|
|
}
|
|
if err != nil {
|
|
respond.Error(w, http.StatusInternalServerError, "could not get volunteer")
|
|
return
|
|
}
|
|
respond.JSON(w, http.StatusOK, v)
|
|
}
|
|
|
|
// 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")
|
|
return
|
|
}
|
|
if err != nil {
|
|
respond.Error(w, http.StatusInternalServerError, "could not update volunteer")
|
|
return
|
|
}
|
|
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})
|
|
}
|