Files
Behavision/server/internal/store/api_team.go
Suriyakumarvijayanayagam 92b12bcb1c 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
2026-09-11 12:12:54 +05:30

417 lines
15 KiB
Go

package store
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/loyaly/behavision-server/internal/api"
)
// Adding people to a company, and taking them out again.
//
// Registration here is by invitation only. `handlers_team.go` carries the
// product argument; what matters at this layer is that every statement is
// scoped by the CALLER'S client id, taken from their session, so a manager
// cannot invite somebody into, list, or remove a member of a company that is
// not theirs by guessing a uuid.
// CreateInvitation writes a pending invitation for one company.
//
// The client id is not trusted from a caller anywhere above this, but it is
// still joined against `clients` here rather than inserted blind: a foreign-key
// violation surfaces as an opaque 500, and a row that names a company which has
// since been deleted is worse than a clean refusal.
func (s *Store) CreateInvitation(ctx context.Context, in api.NewInvitation) (api.Invitation, error) {
var out api.Invitation
err := s.pool.QueryRow(ctx, `
INSERT INTO invitations (client_id, email, full_name, role, code_hash,
invited_by, expires_at)
SELECT c.id, $2, $3, $4, $5, $6::uuid, $7
FROM clients c
WHERE c.id = $1::uuid
RETURNING id::text, email, full_name, role,
to_char(expires_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'),
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`,
in.ClientID, in.Email, in.FullName, in.Role, in.CodeHash,
nullUUID(in.InvitedBy), in.ExpiresAt,
).Scan(&out.ID, &out.Email, &out.FullName, &out.Role,
&out.ExpiresAt, &out.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return api.Invitation{}, errors.New("no such company")
}
if err != nil {
return api.Invitation{}, fmt.Errorf("create invitation: %w", err)
}
return out, nil
}
// PendingInvitations lists the invitations that have been sent and not yet
// taken up. Spent and revoked rows are history and are deliberately not here:
// the question this list answers is "who is still waiting to join".
func (s *Store) PendingInvitations(ctx context.Context, clientID string) ([]api.Invitation, error) {
rows, err := s.pool.Query(ctx, `
SELECT i.id::text, i.email, i.full_name, i.role,
COALESCE(u.full_name, u.email, ''),
to_char(i.expires_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'),
to_char(i.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
FROM invitations i
LEFT JOIN app_users u ON u.id = i.invited_by
WHERE i.client_id = $1::uuid
AND i.used_at IS NULL AND i.revoked_at IS NULL
AND i.expires_at > now()
ORDER BY i.created_at DESC`, clientID)
if err != nil {
return nil, fmt.Errorf("list invitations: %w", err)
}
defer rows.Close()
var out []api.Invitation
for rows.Next() {
var v api.Invitation
if err := rows.Scan(&v.ID, &v.Email, &v.FullName, &v.Role,
&v.InvitedBy, &v.ExpiresAt, &v.CreatedAt); err != nil {
return nil, err
}
out = append(out, v)
}
return out, rows.Err()
}
// RevokeInvitation withdraws one before it is used.
//
// Scoped by client in the UPDATE, and it refuses an already-spent invitation
// rather than silently doing nothing: "I revoked it" and "somebody had already
// joined with it" need opposite follow-up actions from whoever asked.
func (s *Store) RevokeInvitation(ctx context.Context, clientID, id string) error {
tag, err := s.pool.Exec(ctx, `
UPDATE invitations SET revoked_at = now()
WHERE id = $2::uuid AND client_id = $1::uuid
AND used_at IS NULL AND revoked_at IS NULL`, clientID, id)
if err != nil {
return fmt.Errorf("revoke invitation: %w", err)
}
if tag.RowsAffected() == 0 {
return errors.New("no such pending invitation")
}
return nil
}
// InvitationByCode is the unauthenticated preview: what a holder may learn
// about a code they already have.
//
// Every way of not being valid returns the same error, so this cannot be used
// to tell an expired code from an invented one.
func (s *Store) InvitationByCode(ctx context.Context, hash []byte) (api.InvitationPreview, error) {
var out api.InvitationPreview
err := s.pool.QueryRow(ctx, `
SELECT c.name, i.email, i.full_name, i.role
FROM invitations i
JOIN clients c ON c.id = i.client_id
WHERE i.code_hash = $1
AND i.used_at IS NULL AND i.revoked_at IS NULL
AND i.expires_at > now()`, hash,
).Scan(&out.Client, &out.Email, &out.FullName, &out.Role)
if err != nil {
return api.InvitationPreview{}, errors.New("that invitation is not valid")
}
return out, nil
}
// RedeemInvitation turns a code into an account, in ONE transaction.
//
// Two properties, and both were learned elsewhere in this system:
//
// - Single use is enforced BY the update. `used_at IS NULL` and the write are
// one statement, so two people racing on one invitation cannot both win.
// Check-then-update would be exactly that race, and the loser would get a
// second account rather than an error.
// - The account and the redemption commit together. A spent invitation with
// no user behind it is an invitation nobody can use and nobody can see is
// broken; a user with the invitation still open is a second account waiting
// to be created by anyone who was forwarded the code.
//
// The email and the role come from the ROW, never from the request. A code
// passed on to a colleague must not become an account for them, and a staff
// invitation must not be redeemed as an owner.
func (s *Store) RedeemInvitation(ctx context.Context, hash []byte,
fullName, passwordHash string) (api.UserRecord, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return api.UserRecord{}, err
}
defer tx.Rollback(ctx) //nolint:errcheck // no-op once committed
var clientID, email, role, invitedName string
err = tx.QueryRow(ctx, `
UPDATE invitations SET used_at = now()
WHERE code_hash = $1
AND used_at IS NULL AND revoked_at IS NULL AND expires_at > now()
RETURNING client_id::text, email, role, full_name`, hash,
).Scan(&clientID, &email, &role, &invitedName)
if errors.Is(err, pgx.ErrNoRows) {
return api.UserRecord{}, errors.New("that invitation is not valid")
}
if err != nil {
return api.UserRecord{}, fmt.Errorf("redeem invitation: %w", err)
}
if fullName == "" {
// The inviter may have typed a name; use it rather than leaving a
// blank row that every screen then renders as an email address.
fullName = invitedName
}
var rec api.UserRecord
err = tx.QueryRow(ctx, `
INSERT INTO app_users (client_id, email, password_hash, full_name, role)
VALUES ($1::uuid, $2, $3, $4, $5)
RETURNING id::text, email, full_name, role`,
clientID, email, passwordHash, fullName, role,
).Scan(&rec.ID, &rec.Email, &rec.FullName, &rec.Role)
if err != nil {
return api.UserRecord{}, fmt.Errorf("create user: %w", err)
}
var clientName string
if err := tx.QueryRow(ctx, `SELECT name FROM clients WHERE id = $1::uuid`,
clientID).Scan(&clientName); err != nil {
return api.UserRecord{}, err
}
// Recorded against the new account, not the inviter: this is the moment a
// person gained access, and the row should name who did.
rec.ClientID, rec.ClientName, rec.Active, rec.Found = clientID, clientName, true, true
if err := tx.Commit(ctx); err != nil {
return api.UserRecord{}, err
}
return rec, nil
}
// Team lists the people in one company.
func (s *Store) Team(ctx context.Context, clientID string) ([]api.TeamMember, error) {
rows, err := s.pool.Query(ctx, `
SELECT id::text, email, full_name, role, active,
COALESCE(to_char(last_login_at AT TIME ZONE 'UTC',
'YYYY-MM-DD"T"HH24:MI:SS"Z"'), ''),
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
FROM app_users
WHERE client_id = $1::uuid
ORDER BY active DESC, full_name, email`, clientID)
if err != nil {
return nil, fmt.Errorf("list team: %w", err)
}
defer rows.Close()
var out []api.TeamMember
for rows.Next() {
var m api.TeamMember
if err := rows.Scan(&m.ID, &m.Email, &m.FullName, &m.Role, &m.Active,
&m.LastLoginAt, &m.CreatedAt); err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}
// UpdateTeamMember changes a role, or deactivates somebody who has left.
//
// Deactivating REVOKES their sessions in the same transaction. Leaving them
// live would mean "remove their access" removed it in twelve hours' time,
// whenever their access token happened to expire - which is not what anybody
// pressing that button believes they have just done, and is precisely the case
// an opaque-token session table exists to handle.
func (s *Store) UpdateTeamMember(ctx context.Context, clientID, userID string,
up api.TeamUpdate) (api.TeamMember, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return api.TeamMember{}, err
}
defer tx.Rollback(ctx) //nolint:errcheck // no-op once committed
var m api.TeamMember
err = tx.QueryRow(ctx, `
UPDATE app_users
SET role = COALESCE($3, role),
active = COALESCE($4, active)
WHERE id = $2::uuid AND client_id = $1::uuid
RETURNING id::text, email, full_name, role, active,
COALESCE(to_char(last_login_at AT TIME ZONE 'UTC',
'YYYY-MM-DD"T"HH24:MI:SS"Z"'), ''),
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`,
clientID, userID, up.Role, up.Active,
).Scan(&m.ID, &m.Email, &m.FullName, &m.Role, &m.Active,
&m.LastLoginAt, &m.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return api.TeamMember{}, errors.New("no such team member")
}
if err != nil {
return api.TeamMember{}, fmt.Errorf("update team member: %w", err)
}
if up.Active != nil && !*up.Active {
if _, err := tx.Exec(ctx, `
UPDATE sessions SET revoked_at = now()
WHERE user_id = $1::uuid AND revoked_at IS NULL`, userID); err != nil {
return api.TeamMember{}, fmt.Errorf("revoke sessions: %w", err)
}
}
if err := tx.Commit(ctx); err != nil {
return api.TeamMember{}, err
}
return m, nil
}
// OwnerCount counts the active owners of a company.
//
// Used to refuse the change that locks a company out of its own account: the
// last owner may not demote or deactivate themselves. There is no support path
// back from that except a shell on the server, which is the thing this whole
// surface exists to stop needing.
func (s *Store) OwnerCount(ctx context.Context, clientID string) (int, error) {
var n int
err := s.pool.QueryRow(ctx, `
SELECT count(*) FROM app_users
WHERE client_id = $1::uuid AND role = 'owner' AND active`, clientID).Scan(&n)
return n, err
}
// ============================================================== sessions ====
// UserSessions lists one person's live sessions, newest first.
func (s *Store) UserSessions(ctx context.Context, userID string) ([]api.DeviceSession, error) {
rows, err := s.pool.Query(ctx, `
SELECT id::text, device,
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'),
COALESCE(to_char(last_used_at AT TIME ZONE 'UTC',
'YYYY-MM-DD"T"HH24:MI:SS"Z"'), ''),
to_char(refresh_expires_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
FROM sessions
WHERE user_id = $1::uuid AND revoked_at IS NULL
AND refresh_expires_at > now()
ORDER BY COALESCE(last_used_at, created_at) DESC`, userID)
if err != nil {
return nil, fmt.Errorf("list sessions: %w", err)
}
defer rows.Close()
var out []api.DeviceSession
for rows.Next() {
var d api.DeviceSession
if err := rows.Scan(&d.ID, &d.Device, &d.CreatedAt,
&d.LastUsedAt, &d.ExpiresAt); err != nil {
return nil, err
}
out = append(out, d)
}
return out, rows.Err()
}
// RevokeUserSession signs one device out.
//
// Scoped by user_id in the UPDATE, so a session id - which is not a secret and
// travels in a list - cannot be used to sign somebody else out.
func (s *Store) RevokeUserSession(ctx context.Context, userID, sessionID string) error {
tag, err := s.pool.Exec(ctx, `
UPDATE sessions SET revoked_at = now()
WHERE id = $2::uuid AND user_id = $1::uuid AND revoked_at IS NULL`,
userID, sessionID)
if err != nil {
return fmt.Errorf("revoke session: %w", err)
}
if tag.RowsAffected() == 0 {
return errors.New("no such session")
}
return nil
}
// RevokeOtherSessions is the "sign out everywhere else" button.
//
// It keeps the caller's own session deliberately: somebody who has just lost a
// phone should not also be signed out of the device they are holding, which
// would leave them re-authenticating in the middle of an emergency.
func (s *Store) RevokeOtherSessions(ctx context.Context, userID, keepSessionID string) (int, error) {
tag, err := s.pool.Exec(ctx, `
UPDATE sessions SET revoked_at = now()
WHERE user_id = $1::uuid AND id <> $2::uuid AND revoked_at IS NULL`,
userID, keepSessionID)
if err != nil {
return 0, fmt.Errorf("revoke sessions: %w", err)
}
return int(tag.RowsAffected()), nil
}
// CreateMember inserts an active account into a tenant.
//
// The email uniqueness constraint is global (migration 007), and a clash here
// is an ordinary typing mistake - somebody already has that address - so it
// surfaces as a conflict the manager can act on, not a 500.
func (s *Store) CreateMember(ctx context.Context, clientID string,
in api.NewMemberInput, hash string) (api.TeamMember, error) {
var m api.TeamMember
err := s.pool.QueryRow(ctx, `
INSERT INTO app_users (client_id, email, password_hash, full_name, role)
VALUES ($1::uuid, $2, $3, $4, $5)
RETURNING id::text, email, full_name, role, active, '',
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`,
clientID, in.Email, hash, in.FullName, in.Role,
).Scan(&m.ID, &m.Email, &m.FullName, &m.Role, &m.Active,
&m.LastLoginAt, &m.CreatedAt)
if err != nil {
return api.TeamMember{}, fmt.Errorf("create member: %w", err)
}
return m, nil
}
// ResetMemberPassword replaces a member's password and signs them out
// everywhere, in one transaction.
//
// The two go together because of why a manager resets a password at all: the
// salesperson forgot it, or lost the phone it was saved on. In the second case
// the old sessions are the problem, and a reset that left them valid would
// look complete while changing nothing that mattered. Scoped to the caller's
// tenant in the UPDATE itself, so a user id from another company matches no
// row rather than being reset.
func (s *Store) ResetMemberPassword(ctx context.Context, clientID, userID,
hash string) (api.TeamMember, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return api.TeamMember{}, err
}
defer tx.Rollback(ctx) //nolint:errcheck // no-op once committed
var m api.TeamMember
err = tx.QueryRow(ctx, `
UPDATE app_users SET password_hash = $3
WHERE id = $2::uuid AND client_id = $1::uuid
RETURNING id::text, email, full_name, role, active,
COALESCE(to_char(last_login_at AT TIME ZONE 'UTC',
'YYYY-MM-DD"T"HH24:MI:SS"Z"'), ''),
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`,
clientID, userID, hash,
).Scan(&m.ID, &m.Email, &m.FullName, &m.Role, &m.Active,
&m.LastLoginAt, &m.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return api.TeamMember{}, errors.New("no such team member")
}
if err != nil {
return api.TeamMember{}, fmt.Errorf("reset password: %w", err)
}
if _, err := tx.Exec(ctx, `
UPDATE sessions SET revoked_at = now()
WHERE user_id = $1::uuid AND revoked_at IS NULL`, userID); err != nil {
return api.TeamMember{}, fmt.Errorf("revoke sessions: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return api.TeamMember{}, err
}
return m, nil
}