Files
Behavision/server/internal/api/handlers_team.go
Suriyakumarvijayanayagam 92b12bcb1c A merchant can create a salesperson's login and hand it over
The flow this product is sold on is three tiers: the platform admin
registers a merchant, the merchant registers their sales staff, the
staff sign in on a phone. Tier 1 handed the new owner a password. Tier 2
could not - a manager could only mint an invitation code, which the
salesperson had to redeem themselves, on their own phone, choosing their
own password. Good practice, and no use to a manager setting somebody up
before their first shift with a card and a pen.

POST /api/team/members mirrors POST /api/admin/clients: generated
password unless one is given, returned exactly once, bcrypt-hashed on
the way in and not recoverable after. Same permission shape as an
invitation - manager and above, only an owner mints an owner, admin
refused - so a manager cannot do through one door what they are refused
at the other. The invitation path stays; it is the better one whenever
the salesperson has their phone.

POST /api/team/{id}/password is the everyday case on a shop floor:
they forgot it. It sets a new one AND revokes every session they hold,
in one transaction, because the other reason a manager resets a
password is a lost phone, and a reset that left that phone signed in
would look complete while fixing nothing. Tenant-scoped in the UPDATE
itself; another company's user id is 404, never 403. No self-service
and no reset-by-email, deliberately: a floor account often has no
mailbox anyone checks, and the person who can vouch for the salesperson
standing in front of them is their manager.

RandomPassword moves from a private helper in the store to auth, so the
admin path, the merchant path and the reset all mint the same 80-bit
credential - rather than someone later writing a shorter one for the
"less important" account.

Verified: eight handler tests, and two against a real Postgres for the
things a fake cannot see - the RETURNING list scans on a row with no
last_login_at, the tenant scope holds, and the sessions row is actually
revoked. The tenant cleanup from yesterday held throughout.

API.md now documents the chain with both paths, and the note saying a
merchant could not create a login directly is gone because it is no
longer true.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-11 12:12:54 +05:30

522 lines
17 KiB
Go

package api
import (
"net/http"
"strings"
"time"
"github.com/loyaly/behavision-server/internal/auth"
)
// Adding people to a company: invitations, registration, and the team list.
//
// Registration is by INVITATION, and that is the same decision handlers_admin.go
// records for creating a company: an endpoint a stranger can call to create an
// account is a far larger thing to secure than one reachable only through
// somebody who already has one. What was missing was not the openness - it was
// that a tenant could not add a SECOND person at all except by somebody with a
// shell on the server running `provision user`. A shop with an owner and four
// staff either shared one password between five people or raised a ticket per
// person, and a phone app for shop-floor staff could not exist while there was
// only ever one account to sign in as.
//
// So: a manager mints a code, hands it over, and the holder chooses their own
// password. The code carries the address and the role; the request carries only
// the password and a name. That split is load-bearing and is why this is not
// simply "create a user with these fields" - see handleRegister.
const (
// Long enough to reach somebody who is not at work today, short enough that
// a code left in a chat thread is worthless before anyone scrolls back to
// it. An expired invitation costs one click to reissue.
invitationTTL = 7 * 24 * time.Hour
maxInvitation = 30 * 24 * time.Hour
)
// handleInvite mints one invitation.
//
// Manager and above. Not staff: the holder of a code gets an account inside
// this company, so it is a credential, not a convenience.
func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
if !p.CanManageSites() || p.ClientID == "" {
writeErr(w, http.StatusForbidden, "forbidden",
"Your account cannot invite people to this company.")
return
}
var body struct {
Email string `json:"email"`
FullName string `json:"full_name"`
Role string `json:"role"`
Days int `json:"expires_in_days"`
}
if err := decode(w, r, &body); err != nil {
badRequest(w, err.Error())
return
}
email := auth.NormalizeEmail(body.Email)
if email == "" || !strings.Contains(email, "@") {
badRequest(w, "an email address is required - it is what they will sign in with")
return
}
role := strings.ToLower(trim(body.Role))
if role == "" {
role = "staff"
}
// 'admin' is absent on purpose. A platform administrator is defined by
// having no company at all, so an invitation could never mint a real one -
// what it could do is create the tenant-scoped row with role='admin' that
// adminOnly exists to reject, and a role nothing can use is a trap rather
// than a feature.
switch role {
case "owner", "manager", "staff":
default:
badRequest(w, "role must be owner, manager or staff")
return
}
// Only an owner may create another owner. A manager promoting somebody past
// themselves is an escalation, and it is the one shape of this endpoint
// that would matter if a manager account were ever taken over.
if role == "owner" && p.Role != "owner" && p.Role != "admin" {
writeErr(w, http.StatusForbidden, "forbidden",
"Only an owner can invite another owner.")
return
}
ttl := invitationTTL
if body.Days > 0 {
ttl = time.Duration(body.Days) * 24 * time.Hour
if ttl > maxInvitation {
ttl = maxInvitation
}
}
code, err := auth.NewEnrolmentCode()
if err != nil {
s.serverError(w, "mint invitation", err)
return
}
inv, err := s.Store.CreateInvitation(r.Context(), NewInvitation{
ClientID: p.ClientID,
Email: email,
FullName: clip(trim(body.FullName), 200),
Role: role,
CodeHash: auth.HashToken(auth.NormalizeCode(code)),
InvitedBy: p.UserID,
ExpiresAt: s.now().Add(ttl),
})
if err != nil {
s.serverError(w, "create invitation", err)
return
}
// The plaintext exists here and in this response, and nowhere else. Like
// every other secret this system mints, it is shown once: one a support
// engineer can look up later is one anybody with support access can redeem.
inv.Code = code
s.Store.Audit(r.Context(), AuditEntry{
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
Action: "team.invite", Entity: "invitation", EntityID: inv.ID,
Detail: map[string]any{"email": email, "role": role},
})
writeJSON(w, http.StatusCreated, inv)
}
func (s *Server) handleInvitations(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
if !p.CanManageSites() || p.ClientID == "" {
writeErr(w, http.StatusForbidden, "forbidden",
"Your account cannot see this company's invitations.")
return
}
rows, err := s.Store.PendingInvitations(r.Context(), p.ClientID)
if err != nil {
s.serverError(w, "list invitations", err)
return
}
if rows == nil {
rows = []Invitation{}
}
writeJSON(w, http.StatusOK, rows)
}
func (s *Server) handleRevokeInvitation(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
if !p.CanManageSites() || p.ClientID == "" {
writeErr(w, http.StatusForbidden, "forbidden",
"Your account cannot withdraw invitations.")
return
}
id := r.PathValue("id")
if !looksLikeUUID(id) {
writeErr(w, http.StatusNotFound, "not_found", "No such invitation.")
return
}
if err := s.Store.RevokeInvitation(r.Context(), p.ClientID, id); err != nil {
// Already used or already withdrawn. Reported rather than swallowed:
// "I cancelled it" and "somebody had already joined with it" need
// opposite next steps from whoever pressed the button.
writeErr(w, http.StatusNotFound, "not_found",
"That invitation is no longer pending.")
return
}
s.Store.Audit(r.Context(), AuditEntry{
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
Action: "team.invite.revoke", Entity: "invitation", EntityID: id,
})
w.WriteHeader(http.StatusNoContent)
}
// handleInvitationPreview lets a client show what a code is for before asking
// somebody to choose a password.
//
// Unauthenticated, because the holder has no account yet - that is the whole
// point - and it discloses only what the code itself already asserts: the
// company, the address it was issued for, and the role. Unknown, expired, spent
// and revoked are one identical answer, exactly as enrolment already does:
// telling them apart only helps somebody guessing codes, and the holder's next
// step is the same in all four cases.
func (s *Server) handleInvitationPreview(w http.ResponseWriter, r *http.Request) {
code := auth.NormalizeCode(r.URL.Query().Get("code"))
if code == "" {
badRequest(w, "a code is required")
return
}
prev, err := s.Store.InvitationByCode(r.Context(), auth.HashToken(code))
if err != nil {
writeErr(w, http.StatusNotFound, "invalid_code",
"That invitation code is not valid. Ask for a new one.")
return
}
writeJSON(w, http.StatusOK, prev)
}
// handleRegister turns a code into an account and signs the person in.
//
// Unauthenticated for the same reason `POST /api/agent/enrol` is: whoever is
// doing this has no account yet, and requiring one first would mean shipping a
// password to everybody who needs one.
//
// The email and the role come from the INVITATION, never from this body. A code
// forwarded to a colleague must not become an account for them, and a staff
// invitation must not be redeemed as an owner - which is exactly what a
// caller-supplied role would allow. The only things the request decides are the
// password and the display name.
//
// It returns a Session, identical in shape to login. A new member's next screen
// is the app, not a sign-in form they have to fill in with the password they
// chose four seconds ago.
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
var body struct {
Code string `json:"code"`
FullName string `json:"full_name"`
Password string `json:"password"`
Device string `json:"device"`
}
if err := decode(w, r, &body); err != nil {
badRequest(w, err.Error())
return
}
code := auth.NormalizeCode(body.Code)
if code == "" {
badRequest(w, "an invitation code is required")
return
}
// Throttled on the code, by IP. Redeeming is the one unauthenticated write
// in this package that creates a row, so an unbounded one is a way to grind
// through the code space and to fill a table while doing it.
_, perIP := s.throttles()
ipKey := clientIP(r)
if !perIP.Allow(ipKey) {
writeErr(w, http.StatusTooManyRequests, "too_many_attempts",
"Too many attempts. Wait a few minutes and try again.")
return
}
if err := auth.CheckPasswordPolicy(body.Password); err != nil {
badRequest(w, err.Error())
return
}
hash, err := auth.HashPassword(body.Password)
if err != nil {
s.serverError(w, "hash password", err)
return
}
rec, err := s.Store.RedeemInvitation(r.Context(), auth.HashToken(code),
clip(trim(body.FullName), 200), hash)
if err != nil {
perIP.Fail(ipKey)
if msg, ok := conflictMessage(err); ok {
// The address already has an account somewhere on the platform.
// Worth saying plainly: the fix is to sign in, not to try again.
writeErr(w, http.StatusConflict, "conflict", msg)
return
}
writeErr(w, http.StatusNotFound, "invalid_code",
"That invitation code is not valid. Ask for a new one.")
return
}
perIP.Reset(ipKey)
sess, err := s.mint(r, rec, body.Device)
if err != nil {
// The account exists and the invitation is spent. Say so rather than
// implying nothing happened - the recovery is to sign in, and telling
// them to redeem again would fail forever.
s.logf("ERROR register: created %s but could not start a session: %v", rec.ID, err)
writeErr(w, http.StatusInternalServerError, "server_error",
"Your account was created but we could not sign you in. Please sign in.")
return
}
s.Store.Audit(r.Context(), AuditEntry{
ClientID: rec.ClientID, ActorID: rec.ID, ActorKind: "user",
Action: "team.register", Entity: "user", EntityID: rec.ID,
Detail: map[string]any{"role": rec.Role, "device": trim(body.Device)},
})
s.logf("registered %s (%s) into client %s", rec.Email, rec.Role, rec.ClientID)
writeJSON(w, http.StatusCreated, sess)
}
func (s *Server) handleTeam(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
if p.ClientID == "" {
writeErr(w, http.StatusForbidden, "forbidden",
"This account does not belong to a company.")
return
}
rows, err := s.Store.Team(r.Context(), p.ClientID)
if err != nil {
s.serverError(w, "list team", err)
return
}
if rows == nil {
rows = []TeamMember{}
}
writeJSON(w, http.StatusOK, rows)
}
// handleUpdateTeamMember changes a role, or turns an account off.
//
// Deactivating is the "they have left" button, and the store revokes their
// sessions in the same transaction: an access token lives twelve hours, so
// without that, removing somebody's access would remove it sometime tomorrow.
func (s *Server) handleUpdateTeamMember(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
if !p.CanManageSites() || p.ClientID == "" {
writeErr(w, http.StatusForbidden, "forbidden",
"Your account cannot change who works here.")
return
}
id := r.PathValue("id")
if !looksLikeUUID(id) {
writeErr(w, http.StatusNotFound, "not_found", "No such team member.")
return
}
var up TeamUpdate
if err := decode(w, r, &up); err != nil {
badRequest(w, err.Error())
return
}
if up.Role == nil && up.Active == nil {
badRequest(w, "nothing to change - send a role, an active flag, or both")
return
}
if up.Role != nil {
role := strings.ToLower(trim(*up.Role))
switch role {
case "owner", "manager", "staff":
default:
badRequest(w, "role must be owner, manager or staff")
return
}
if role == "owner" && p.Role != "owner" && p.Role != "admin" {
writeErr(w, http.StatusForbidden, "forbidden",
"Only an owner can make somebody else an owner.")
return
}
up.Role = &role
}
// The company must keep an owner. Losing the last one leaves a tenant
// nobody can administer, and the only way back is a shell on the server -
// which is the thing this whole surface exists to stop needing.
demoting := up.Role != nil && *up.Role != "owner"
disabling := up.Active != nil && !*up.Active
if demoting || disabling {
if last, err := s.lastOwner(r, id); err != nil {
s.serverError(w, "count owners", err)
return
} else if last {
writeErr(w, http.StatusConflict, "last_owner",
"This is the company's only owner. Make somebody else an owner first.")
return
}
}
m, err := s.Store.UpdateTeamMember(r.Context(), p.ClientID, id, up)
if err != nil {
writeErr(w, http.StatusNotFound, "not_found", "No such team member.")
return
}
s.Store.Audit(r.Context(), AuditEntry{
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
Action: "team.update", Entity: "user", EntityID: id,
Detail: map[string]any{"role": m.Role, "active": m.Active},
})
writeJSON(w, http.StatusOK, m)
}
// lastOwner reports whether the named member is the only active owner left.
func (s *Server) lastOwner(r *http.Request, userID string) (bool, error) {
p := PrincipalFrom(r.Context())
rows, err := s.Store.Team(r.Context(), p.ClientID)
if err != nil {
return false, err
}
owners, isOwner := 0, false
for _, m := range rows {
if m.Role == "owner" && m.Active {
owners++
if m.ID == userID {
isOwner = true
}
}
}
return isOwner && owners == 1, nil
}
// handleCreateMember is a manager creating a salesperson's login directly and
// handing it over - the path for somebody being set up before their first
// shift, without a phone in hand.
//
// Same rules as an invitation for who may create whom: manager and above, and
// only an owner mints an owner. Same rule as the platform admin creating a
// merchant for the password: generated unless given, returned exactly once.
func (s *Server) handleCreateMember(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
if !p.CanManageSites() || p.ClientID == "" {
writeErr(w, http.StatusForbidden, "forbidden",
"Only a manager or owner can add team members.")
return
}
var in NewMemberInput
if err := decode(w, r, &in); err != nil {
badRequest(w, err.Error())
return
}
in.Email = auth.NormalizeEmail(in.Email)
if in.Email == "" || !strings.Contains(in.Email, "@") {
badRequest(w, "an email address is required - it is what they will sign in with")
return
}
in.FullName = clip(trim(in.FullName), 200)
in.Role = strings.ToLower(trim(in.Role))
if in.Role == "" {
in.Role = "staff"
}
switch in.Role {
case "owner", "manager", "staff":
default:
badRequest(w, "role must be owner, manager or staff")
return
}
if in.Role == "owner" && p.Role != "owner" && p.Role != "admin" {
writeErr(w, http.StatusForbidden, "forbidden",
"Only an owner can create another owner.")
return
}
password := in.Password
if password == "" {
generated, err := auth.RandomPassword()
if err != nil {
s.serverError(w, "generate password", err)
return
}
password = generated
}
hash, err := auth.HashPassword(password)
if err != nil {
// The policy message ("at least 8 characters") is written for the
// person who typed it, so it goes out as-is.
badRequest(w, err.Error())
return
}
m, err := s.Store.CreateMember(r.Context(), p.ClientID, in, hash)
if err != nil {
if msg, ok := conflictMessage(err); ok {
writeErr(w, http.StatusConflict, "conflict", msg)
return
}
s.serverError(w, "create member", err)
return
}
s.Store.Audit(r.Context(), AuditEntry{
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
Action: "team.create", Entity: "user", EntityID: m.ID,
Detail: map[string]any{"email": m.Email, "role": m.Role},
})
// The plaintext exists here and in this response, and nowhere else.
writeJSON(w, http.StatusCreated, NewMemberResult{TeamMember: m, Password: password})
}
// handleResetPassword is a manager resetting a member's password: the
// salesperson forgot it, or lost the phone it was on. Returns the new one
// once, and signs the member out everywhere - see the store for why those are
// one operation.
//
// Deliberately not self-service and not "send an email": a shop-floor account
// often has no mailbox anyone checks, and the person who can vouch for the
// salesperson standing in front of them is their manager.
func (s *Server) handleResetPassword(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
if !p.CanManageSites() || p.ClientID == "" {
writeErr(w, http.StatusForbidden, "forbidden",
"Only a manager or owner can reset a team member's password.")
return
}
userID := r.PathValue("id")
var in PasswordReset
if err := decodeOptional(w, r, &in); err != nil {
badRequest(w, err.Error())
return
}
password := in.Password
if password == "" {
generated, err := auth.RandomPassword()
if err != nil {
s.serverError(w, "generate password", err)
return
}
password = generated
}
hash, err := auth.HashPassword(password)
if err != nil {
badRequest(w, err.Error())
return
}
m, err := s.Store.ResetMemberPassword(r.Context(), p.ClientID, userID, hash)
if err != nil {
// A user id from another tenant matches nothing, so it reads as 404 -
// a tenant user has no business learning the id was real.
writeErr(w, http.StatusNotFound, "not_found", "No such team member.")
return
}
s.Store.Audit(r.Context(), AuditEntry{
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
Action: "team.reset_password", Entity: "user", EntityID: m.ID,
Detail: map[string]any{"email": m.Email},
})
writeJSON(w, http.StatusOK, PasswordReset{Password: password})
}