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}",