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

@@ -63,6 +63,14 @@ type Store interface {
RedeemInvitation(ctx context.Context, hash []byte, fullName, passwordHash string) (UserRecord, error)
Team(ctx context.Context, clientID string) ([]TeamMember, error)
UpdateTeamMember(ctx context.Context, clientID, userID string, up TeamUpdate) (TeamMember, error)
// CreateMember inserts an active account into the caller's tenant. The
// hash is computed by the handler, so the plaintext never reaches the
// store - same boundary invitations and sessions already keep.
CreateMember(ctx context.Context, clientID string, in NewMemberInput, hash string) (TeamMember, error)
// ResetMemberPassword replaces the hash and revokes every session the
// member holds, in one transaction. A reset is what happens after a lost
// phone; leaving that phone signed in would defeat it.
ResetMemberPassword(ctx context.Context, clientID, userID, hash string) (TeamMember, error)
// --- public references ---
// Resolving the names people actually use to the uuids the schema stores.
@@ -246,6 +254,8 @@ func (s *Server) Routes() *http.ServeMux {
// --- the people who work here ---
mux.HandleFunc("GET /api/team", s.authed(s.handleTeam))
mux.HandleFunc("PATCH /api/team/{id}", s.authed(s.handleUpdateTeamMember))
mux.HandleFunc("POST /api/team/members", s.authed(s.handleCreateMember))
mux.HandleFunc("POST /api/team/{id}/password", s.authed(s.handleResetPassword))
mux.HandleFunc("GET /api/team/invitations", s.authed(s.handleInvitations))
mux.HandleFunc("POST /api/team/invitations", s.authed(s.handleInvite))
mux.HandleFunc("DELETE /api/team/invitations/{id}",

View File

@@ -995,3 +995,59 @@ func (f *fakeStore) VisitorIDByNumber(_ context.Context, clientID string, number
}
return "", nil
}
// CreateMember behaves like the real store on the two things the handler
// branches on: the account lands in the caller's tenant and nowhere else, and
// an address that already exists anywhere is a conflict named the way Postgres
// names it, so conflictMessage recognises it.
func (f *fakeStore) CreateMember(_ context.Context, clientID string,
in NewMemberInput, hash string) (TeamMember, error) {
f.mu.Lock()
defer f.mu.Unlock()
if _, taken := f.users[in.Email]; taken {
return TeamMember{}, errors.New(`duplicate key value violates unique constraint "app_users_email_idx"`)
}
// The real UserByEmail joins clients for the name; this fake reads it off
// the record, so copy it from a tenant-mate or a login as the new member
// comes back with no company name and looks like it landed nowhere.
clientName := ""
for _, u := range f.users {
if u.ClientID == clientID && u.ClientName != "" {
clientName = u.ClientName
break
}
}
id := "member-" + itoa(len(f.users)+1)
f.users[in.Email] = UserRecord{
ID: id, ClientID: clientID, ClientName: clientName,
Email: in.Email, FullName: in.FullName,
Role: in.Role, Active: true, PasswordHash: hash, Found: true,
}
return TeamMember{ID: id, Email: in.Email, FullName: in.FullName,
Role: in.Role, Active: true}, nil
}
// ResetMemberPassword mirrors the real one: tenant-scoped, and every session
// the member holds is revoked with it.
func (f *fakeStore) ResetMemberPassword(_ context.Context, clientID, userID,
hash string) (TeamMember, error) {
f.mu.Lock()
defer f.mu.Unlock()
for email, u := range f.users {
if u.ID != userID || u.ClientID != clientID {
continue
}
u.PasswordHash = hash
f.users[email] = u
for _, s := range f.sessions {
if s.p.UserID == userID {
s.revoked = true
}
}
return TeamMember{ID: u.ID, Email: u.Email, FullName: u.FullName,
Role: u.Role, Active: u.Active}, nil
}
return TeamMember{}, errors.New("no such team member")
}

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})
}

View File

@@ -0,0 +1,201 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"testing"
)
// The second way a salesperson gets a login: their manager creates it and hands
// it over. Everything here is a property of the one rule that path lives by -
// the password is shown once, to the manager, and to nobody afterwards.
func createMember(t *testing.T, s *Server, token string, body map[string]any) (int, NewMemberResult, string) {
t.Helper()
rec := do(t, s, "POST", "/api/team/members", token, body)
var out NewMemberResult
if rec.Code == http.StatusCreated {
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
}
return rec.Code, out, rec.Body.String()
}
func TestAManagerCanCreateALoginAndHandItOver(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
mgr := login(t, s, "manager@acme.com", "correct horse battery")
code, out, body := createMember(t, s, mgr.Token, map[string]any{
"email": "Priya@Acme.com", "full_name": "Priya R", "role": "staff"})
if code != http.StatusCreated {
t.Fatalf("create: got %d, body %s", code, body)
}
// Generated, not blank, and long enough to be a credential rather than a
// suggestion. The manager reads this off the screen onto a card.
if len(out.Password) < 12 {
t.Fatalf("password should be generated when not given, got %q", out.Password)
}
if out.Email != "priya@acme.com" || out.Role != "staff" || !out.Active {
t.Fatalf("member not as created: %+v", out.TeamMember)
}
// The whole point: the salesperson can sign in with what the manager was
// shown, right now, on their own phone.
sess := login(t, s, "priya@acme.com", out.Password)
if sess.User.Client != "Acme Retail" || sess.User.Role != "staff" {
t.Fatalf("the new member landed somewhere odd: %+v", sess.User)
}
}
// The password is returned by the request that set it and by nothing else. A
// credential a manager can look up later is one anybody at that screen can
// read off, and the team list is on screen all day.
func TestThePasswordIsShownOnceAndNeverListed(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
mgr := login(t, s, "manager@acme.com", "correct horse battery")
_, out, _ := createMember(t, s, mgr.Token, map[string]any{"email": "sam@acme.com"})
rec := do(t, s, "GET", "/api/team", mgr.Token, nil)
if strings.Contains(rec.Body.String(), out.Password) {
t.Fatal("the team list carries a password")
}
if strings.Contains(rec.Body.String(), `"password"`) {
t.Fatal("the team list has a password field at all")
}
}
// Same shape of permission as an invitation, on purpose: the two paths create
// the same thing, so a manager must not be able to do through one what they
// are refused through the other.
func TestStaffCannotCreateAndAManagerCannotCreateAnOwner(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
seedMember(fs, acmeStaffID, "staff@acme.com", "Sam", "staff")
seedMember(fs, acmeOwnerID, "owner@acme.com", "Olu", "owner")
staff := login(t, s, "staff@acme.com", "correct horse battery")
if code, _, _ := createMember(t, s, staff.Token, map[string]any{"email": "x@acme.com"}); code != http.StatusForbidden {
t.Fatalf("staff creating a login: want 403, got %d", code)
}
mgr := login(t, s, "manager@acme.com", "correct horse battery")
if code, _, _ := createMember(t, s, mgr.Token, map[string]any{"email": "boss@acme.com", "role": "owner"}); code != http.StatusForbidden {
t.Fatalf("manager minting an owner: want 403, got %d", code)
}
owner := login(t, s, "owner@acme.com", "correct horse battery")
if code, _, body := createMember(t, s, owner.Token, map[string]any{"email": "boss@acme.com", "role": "owner"}); code != http.StatusCreated {
t.Fatalf("owner minting an owner: want 201, got %d %s", code, body)
}
// Never admin. A platform admin is defined by having no company, so this
// could only ever mint the tenant-scoped role='admin' row that adminOnly
// exists to reject.
if code, _, _ := createMember(t, s, owner.Token, map[string]any{"email": "root@acme.com", "role": "admin"}); code != http.StatusBadRequest {
t.Fatalf("role=admin: want 400, got %d", code)
}
}
func TestAnAddressThatAlreadyExistsIsAConflictNotAFault(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
mgr := login(t, s, "manager@acme.com", "correct horse battery")
code, _, body := createMember(t, s, mgr.Token, map[string]any{"email": "manager@acme.com"})
if code != http.StatusConflict {
t.Fatalf("want 409, got %d %s", code, body)
}
if !strings.Contains(body, "already has an account") {
t.Fatalf("the message should say what to do about it: %s", body)
}
}
// A manager may choose the password, but not a bad one. The floor is the same
// as everywhere else, and the policy message goes to them unchanged.
func TestAChosenPasswordStillMeetsTheFloor(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
mgr := login(t, s, "manager@acme.com", "correct horse battery")
code, _, body := createMember(t, s, mgr.Token, map[string]any{"email": "a@acme.com", "password": "short"})
if code != http.StatusBadRequest {
t.Fatalf("want 400, got %d %s", code, body)
}
code, out, _ := createMember(t, s, mgr.Token, map[string]any{"email": "b@acme.com", "password": "chosen-by-manager"})
if code != http.StatusCreated || out.Password != "chosen-by-manager" {
t.Fatalf("a valid chosen password should be used and echoed once, got %d %q", code, out.Password)
}
}
// Why a manager resets a password: the salesperson forgot it, or lost the
// phone it was saved on. In the second case the phone is the problem, so the
// reset that fixes the first must also fix the second.
func TestAResetSignsTheOldPhoneOutAndTheNewPasswordIn(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
seedMember(fs, acmeStaffID, "priya@acme.com", "Priya", "staff")
mgr := login(t, s, "manager@acme.com", "correct horse battery")
lostPhone := login(t, s, "priya@acme.com", "correct horse battery")
rec := do(t, s, "POST", "/api/team/"+acmeStaffID+"/password", mgr.Token, nil)
if rec.Code != http.StatusOK {
t.Fatalf("reset: %d %s", rec.Code, rec.Body.String())
}
var out PasswordReset
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
if len(out.Password) < 12 {
t.Fatalf("reset should hand back a generated password, got %q", out.Password)
}
// The lost phone is out.
if rec := do(t, s, "GET", "/api/auth/me", lostPhone.Token, nil); rec.Code != http.StatusUnauthorized {
t.Fatalf("the old session should be revoked by a reset, got %d", rec.Code)
}
// The old password is dead.
if rec := do(t, s, "POST", "/api/auth/login", "", map[string]any{
"email": "priya@acme.com", "password": "correct horse battery"}); rec.Code != http.StatusUnauthorized {
t.Fatalf("the old password still works after a reset, got %d", rec.Code)
}
// The new one is alive.
login(t, s, "priya@acme.com", out.Password)
}
// A user id is not a secret and this endpoint hands out a credential, so it
// must not be reachable across tenants - and it must read as "no such person",
// not as "that id is real but not yours".
func TestAResetCannotReachAnotherCompanysStaff(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
fs.addUser("theirs@other.com", "correct horse battery", UserRecord{
ID: acmeOtherID, ClientID: "client-other", ClientName: "Other Ltd",
FullName: "Theo", Role: "staff", Active: true,
})
mgr := login(t, s, "manager@acme.com", "correct horse battery")
rec := do(t, s, "POST", "/api/team/"+acmeOtherID+"/password", mgr.Token, nil)
if rec.Code != http.StatusNotFound {
t.Fatalf("cross-tenant reset: want 404, got %d", rec.Code)
}
// And nothing happened to them.
login(t, s, "theirs@other.com", "correct horse battery")
}
func TestStaffCannotResetAnyonesPassword(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
seedMember(fs, acmeStaffID, "staff@acme.com", "Sam", "staff")
staff := login(t, s, "staff@acme.com", "correct horse battery")
rec := do(t, s, "POST", "/api/team/"+acmeStaffID+"/password", staff.Token, nil)
if rec.Code != http.StatusForbidden {
t.Fatalf("want 403, got %d", rec.Code)
}
}

View File

@@ -695,6 +695,38 @@ type TeamUpdate struct {
Active *bool `json:"active,omitempty"`
}
// NewMemberInput is a staff account created directly by a manager, with a
// password the manager hands over.
//
// The other path - an invitation the salesperson redeems on their own phone -
// is better when it fits: the manager never touches the password. It does not
// fit a salesperson being set up before their first shift, without a phone in
// hand, by somebody who wants to write a login on a card and be done. This is
// that path, and it mirrors how the platform admin creates a merchant owner:
// same generated password, same shown-once rule.
type NewMemberInput struct {
Email string `json:"email"`
FullName string `json:"full_name"`
Role string `json:"role"`
// Password is optional. Empty means "generate one", which is the better
// default for the same reason it is on the admin side.
Password string `json:"password"`
}
// NewMemberResult is the member plus the one moment their password is readable.
type NewMemberResult struct {
TeamMember
// Password is shown once. It is bcrypt-hashed on the way in and is not
// recoverable afterwards.
Password string `json:"password"`
}
// PasswordReset is both the optional request ("use this one") and the response
// ("here is the one that was set") for a manager resetting a member's password.
type PasswordReset struct {
Password string `json:"password"`
}
// ==================================================== devices and sessions ==
// DeviceSession is one signed-in device, as its owner sees it.