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

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

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

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

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

View File

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