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
107 lines
3.5 KiB
Go
107 lines
3.5 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/loyaly/behavision-server/internal/api"
|
|
"github.com/loyaly/behavision-server/internal/auth"
|
|
)
|
|
|
|
// CreateClientWithOwner creates a tenant and the account that owns it.
|
|
//
|
|
// ONE transaction, deliberately. A client row with no owner is a tenant nobody
|
|
// can sign into, and it is invisible: it looks exactly like a normal client in
|
|
// every list, so the operator finds out weeks later when the customer says
|
|
// their login does not work. Rolling the whole thing back on a duplicate email
|
|
// is the only outcome that leaves the database describing something real.
|
|
func (s *Store) CreateClientWithOwner(ctx context.Context, in api.NewClientInput) (
|
|
api.NewClientResult, error) {
|
|
|
|
var out api.NewClientResult
|
|
|
|
password := in.Password
|
|
if password == "" {
|
|
// Generated rather than defaulted. An operator inventing a password for
|
|
// somebody else invents a weak one and then sends it over chat.
|
|
p, err := auth.RandomPassword()
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
password = p
|
|
}
|
|
hash, err := auth.HashPassword(password)
|
|
if err != nil {
|
|
// The policy message is user-facing text an operator can act on
|
|
// ("password must be at least 8 characters"), so it travels out as-is.
|
|
return out, err
|
|
}
|
|
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
defer tx.Rollback(ctx) //nolint:errcheck // no-op once committed
|
|
|
|
// No ON CONFLICT DO UPDATE here, unlike the provisioning CLI. On this path
|
|
// a clashing slug means the operator is about to hand someone else's tenant
|
|
// to a new owner; it has to fail and say so.
|
|
if err := tx.QueryRow(ctx, `
|
|
INSERT INTO clients (slug, name) VALUES ($1, $2)
|
|
RETURNING id::text`, in.Slug, in.CompanyName).Scan(&out.ClientID); err != nil {
|
|
return out, fmt.Errorf("create client: %w", err)
|
|
}
|
|
|
|
// The owner, not a manager: this is the account the customer runs their
|
|
// business from, and it must be able to add the staff who come after it.
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO app_users (client_id, email, password_hash, full_name, role)
|
|
VALUES ($1::uuid, $2, $3, $4, 'owner')`,
|
|
out.ClientID, in.OwnerEmail, hash, in.OwnerName); err != nil {
|
|
return out, fmt.Errorf("create owner: %w", err)
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return out, err
|
|
}
|
|
out.Slug, out.OwnerEmail, out.Password = in.Slug, in.OwnerEmail, password
|
|
return out, nil
|
|
}
|
|
|
|
// ListClients is the platform-admin overview.
|
|
//
|
|
// Counts come from correlated subqueries rather than joins: a client with two
|
|
// sites and three users would otherwise appear six times and be counted wrong
|
|
// in whichever direction the operator's eye went first.
|
|
func (s *Store) ListClients(ctx context.Context) ([]api.ClientRow, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT c.id::text, c.slug, c.name, c.created_at,
|
|
(SELECT count(*) FROM sites si WHERE si.client_id = c.id),
|
|
(SELECT count(*) FROM app_users au WHERE au.client_id = c.id)
|
|
FROM clients c
|
|
ORDER BY c.created_at DESC`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []api.ClientRow
|
|
for rows.Next() {
|
|
var c api.ClientRow
|
|
var at time.Time
|
|
if err := rows.Scan(&c.ID, &c.Slug, &c.Name, &at, &c.Sites, &c.Users); err != nil {
|
|
return nil, err
|
|
}
|
|
c.CreatedAt = at.UTC().Format(time.RFC3339)
|
|
out = append(out, c)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// randomPassword mints an owner's first password.
|
|
//
|
|
// base32 without padding, matching the rest of this system's generated
|
|
// secrets: it gets read down a phone line and pasted into a form, and base64's
|
|
// + / = survive neither.
|