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:
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user