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
522 lines
17 KiB
Go
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})
|
|
}
|