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
This commit is contained in:
2026-09-11 12:12:54 +05:30
parent c50a74de47
commit 92b12bcb1c
10 changed files with 696 additions and 29 deletions

View File

@@ -388,3 +388,134 @@ func (s *Server) lastOwner(r *http.Request, userID string) (bool, error) {
}
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})
}