Files
descrybe/apps/api/internal/auth/invites.go
T

368 lines
11 KiB
Go
Raw Normal View History

package auth
import (
"context"
"errors"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// Invite is a pending company invite row (plaintext token returned once at creation; hashed at rest).
type Invite struct {
ID uuid.UUID `json:"id"`
CompanyID uuid.UUID `json:"company_id"`
Email string `json:"email"`
Role string `json:"role"`
ExpiresAt time.Time `json:"expires_at"`
}
func normalizeInviteRole(role string) string {
role = strings.TrimSpace(strings.ToLower(role))
if role == "admin" {
return "admin"
}
return "member"
}
// NormalizeMembershipRole maps invite/membership role strings to admin|member.
// Unknown values collapse to member (safe default for invites).
func NormalizeMembershipRole(role string) string {
return normalizeInviteRole(role)
}
// ErrInvalidMembershipRole is returned when a role is not exactly admin|member.
var ErrInvalidMembershipRole = errors.New("invalid membership role")
// ParseMembershipRole accepts only admin|member (case-insensitive). Unlike
// NormalizeMembershipRole it does not coerce unknown values to member — use for
// PATCH/role updates where coercion would silently demote admins.
func ParseMembershipRole(role string) (string, error) {
role = strings.TrimSpace(strings.ToLower(role))
switch role {
case "admin", "member":
return role, nil
default:
return "", ErrInvalidMembershipRole
}
}
// preferMembershipRole keeps admin on invite accept conflict (never demote admin→member).
// Mirrors AcceptInvite ON CONFLICT role CASE.
func preferMembershipRole(existing, invited string) string {
if existing == "admin" {
return "admin"
}
return normalizeInviteRole(invited)
}
// HashInviteToken returns the SHA-256 hex digest stored in invites.token (same construction as API keys).
func HashInviteToken(raw string) string {
return HashAPIKey(raw)
}
// IsSyntheticLegacyEmail reports Clerk-missing synthetic addresses that must not receive invites.
func IsSyntheticLegacyEmail(email string) bool {
email = strings.ToLower(strings.TrimSpace(email))
return strings.HasSuffix(email, "@legacy.local")
}
// EmailsEqual compares emails case-insensitively after trim.
func EmailsEqual(a, b string) bool {
return strings.EqualFold(strings.TrimSpace(a), strings.TrimSpace(b))
}
2026-08-24 04:03:44 +02:00
// Invite password modes for accept-invite UX (also drives AcceptInvite branching).
const (
InvitePasswordSet = "set" // new account — choose a password
InvitePasswordVerify = "verify" // existing account with active memberships — current password
InvitePasswordRecover = "recover" // existing account with no active memberships — set a new password
)
// InvitePasswordMode reports how accept-invite should treat the password field for email.
func (s *Service) InvitePasswordMode(ctx context.Context, email string) (string, error) {
email = strings.ToLower(strings.TrimSpace(email))
if email == "" {
return InvitePasswordSet, nil
}
var (
userID uuid.UUID
mustSet bool
)
err := s.Pool.QueryRow(ctx, `
SELECT id, must_set_password FROM users WHERE email = $1`, email).Scan(&userID, &mustSet)
if errors.Is(err, pgx.ErrNoRows) {
return InvitePasswordSet, nil
}
if err != nil {
return "", err
}
if mustSet {
return InvitePasswordSet, nil
}
var active int64
if err := s.Pool.QueryRow(ctx, `
SELECT count(*) FROM memberships
WHERE user_id = $1 AND status = 'active'`, userID).Scan(&active); err != nil {
return "", err
}
if active == 0 {
return InvitePasswordRecover, nil
}
return InvitePasswordVerify, nil
}
// ResolveInviteEmail returns the invitee email for a pending, unexpired invite token.
func (s *Service) ResolveInviteEmail(ctx context.Context, token string) (string, error) {
token = strings.TrimSpace(token)
if token == "" {
return "", ErrInviteInvalid
}
var (
email string
expiresAt time.Time
acceptedAt *time.Time
)
tokenHash := HashInviteToken(token)
err := s.Pool.QueryRow(ctx, `
SELECT email, expires_at, accepted_at
FROM invites
WHERE token = $1 OR token = $2
ORDER BY CASE WHEN token = $1 THEN 0 ELSE 1 END
LIMIT 1`, tokenHash, token).Scan(&email, &expiresAt, &acceptedAt)
if errors.Is(err, pgx.ErrNoRows) || (acceptedAt != nil) || time.Now().After(expiresAt) {
return "", ErrInviteInvalid
}
if err != nil {
return "", err
}
return strings.ToLower(strings.TrimSpace(email)), nil
}
// SetPasswordInvite is a one-time accept-invite token for a migrated user (plaintext returned once).
type SetPasswordInvite struct {
InviteID uuid.UUID
UserID uuid.UUID
Email string
CompanyID uuid.UUID
Role string
Token string
ExpiresAt time.Time
}
// CreateInvite inserts a pending invite (token hashed at rest) and returns the plaintext token once.
func (s *Service) CreateInvite(ctx context.Context, companyID, invitedBy uuid.UUID, email, role string) (Invite, string, error) {
email = strings.ToLower(strings.TrimSpace(email))
if email == "" {
return Invite{}, "", ErrEmailRequired
}
role = normalizeInviteRole(role)
token, err := RandomToken(24)
if err != nil {
return Invite{}, "", err
}
expires := time.Now().UTC().Add(7 * 24 * time.Hour)
var inv Invite
err = s.Pool.QueryRow(ctx, `
INSERT INTO invites (company_id, email, role, token, invited_by, expires_at)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, company_id, email, role, expires_at`,
companyID, email, role, HashInviteToken(token), invitedBy, expires,
).Scan(&inv.ID, &inv.CompanyID, &inv.Email, &inv.Role, &inv.ExpiresAt)
if err != nil {
return Invite{}, "", err
}
return inv, token, nil
}
// ListPendingInvites returns unaccepted, unexpired invites for a company.
func (s *Service) ListPendingInvites(ctx context.Context, companyID uuid.UUID, limit, offset int) ([]Invite, int64, error) {
const where = `company_id = $1 AND accepted_at IS NULL AND expires_at > now()`
var total int64
if err := s.Pool.QueryRow(ctx, `SELECT count(*) FROM invites WHERE `+where, companyID).Scan(&total); err != nil {
return nil, 0, err
}
rows, err := s.Pool.Query(ctx, `
SELECT id, company_id, email, role, expires_at
FROM invites
WHERE `+where+`
ORDER BY created_at DESC LIMIT $2 OFFSET $3`, companyID, limit, offset)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var out []Invite
for rows.Next() {
var inv Invite
if err := rows.Scan(&inv.ID, &inv.CompanyID, &inv.Email, &inv.Role, &inv.ExpiresAt); err != nil {
return nil, 0, err
}
out = append(out, inv)
}
return out, total, rows.Err()
}
// RevokeInvite deletes a pending invite owned by the company.
func (s *Service) RevokeInvite(ctx context.Context, companyID, inviteID uuid.UUID) error {
ct, err := s.Pool.Exec(ctx, `
DELETE FROM invites
WHERE id = $1 AND company_id = $2 AND accepted_at IS NULL`, inviteID, companyID)
if err != nil {
return err
}
if ct.RowsAffected() == 0 {
return ErrInviteNotFound
}
return nil
}
// CompanyName returns the display name for a company.
func (s *Service) CompanyName(ctx context.Context, companyID uuid.UUID) (string, error) {
var name string
err := s.Pool.QueryRow(ctx, `SELECT name FROM companies WHERE id = $1`, companyID).Scan(&name)
if errors.Is(err, pgx.ErrNoRows) {
return "", errors.New("company not found")
}
return name, err
}
// UpdateMembershipRole sets an active membership role to admin or member.
func (s *Service) UpdateMembershipRole(ctx context.Context, companyID, userID uuid.UUID, role string) (Membership, error) {
role = normalizeInviteRole(role)
var m Membership
err := s.Pool.QueryRow(ctx, `
UPDATE memberships
SET role = $3, updated_at = now()
WHERE company_id = $1 AND user_id = $2 AND status = 'active'
RETURNING id, company_id, user_id, role, status`,
companyID, userID, role,
).Scan(&m.ID, &m.CompanyID, &m.UserID, &m.Role, &m.Status)
if errors.Is(err, pgx.ErrNoRows) {
return Membership{}, ErrNotCompanyMember
}
return m, err
}
// IsPlatformAdmin reports whether the user has full platform admin privileges
// (admin/developer or legacy is_platform_admin). support_staff is excluded.
func (s *Service) IsPlatformAdmin(ctx context.Context, userID uuid.UUID) (bool, error) {
access, err := s.GetStaffAccess(ctx, userID)
if err != nil {
return false, err
}
return access.FullAdmin, nil
}
// UpdateProfile updates the user's display name.
func (s *Service) UpdateProfile(ctx context.Context, userID uuid.UUID, name string) (User, error) {
name = strings.TrimSpace(name)
var n *string
if name != "" {
n = &name
}
_, err := s.Pool.Exec(ctx, `
UPDATE users SET name = $2, updated_at = now() WHERE id = $1`, userID, n)
if err != nil {
return User{}, err
}
return s.GetUser(ctx, userID)
}
// ListUsersNeedingPassword returns active users with must_set_password (migration cutover).
func (s *Service) ListUsersNeedingPassword(ctx context.Context, limit int) ([]User, error) {
if limit <= 0 {
limit = 100
}
rows, err := s.Pool.Query(ctx, `
SELECT id, email, name, must_set_password, is_platform_admin, staff_role, is_active
FROM users
WHERE must_set_password = true AND is_active = true
ORDER BY email
LIMIT $1`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []User
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.MustSetPassword, &u.IsPlatformAdmin, &u.StaffRole, &u.IsActive); err != nil {
return nil, err
}
out = append(out, u)
}
return out, rows.Err()
}
// ReissueSetPasswordInvite expires prior pending invites and creates a durable invite for a
// must_set_password user with an active membership. Token plaintext is returned once.
func (s *Service) ReissueSetPasswordInvite(ctx context.Context, userID uuid.UUID, ttl time.Duration) (SetPasswordInvite, error) {
if ttl <= 0 {
ttl = 7 * 24 * time.Hour
}
var (
out SetPasswordInvite
mustSetPassword bool
isActive bool
)
err := s.Pool.QueryRow(ctx, `
SELECT id, email, must_set_password, is_active
FROM users WHERE id = $1`, userID).Scan(&out.UserID, &out.Email, &mustSetPassword, &isActive)
if errors.Is(err, pgx.ErrNoRows) {
return SetPasswordInvite{}, ErrUserNotFound
}
if err != nil {
return SetPasswordInvite{}, err
}
if !isActive || !mustSetPassword {
return SetPasswordInvite{}, ErrNotEligibleSetPassword
}
if IsSyntheticLegacyEmail(out.Email) {
return SetPasswordInvite{}, ErrSyntheticEmail
}
out.Email = strings.ToLower(strings.TrimSpace(out.Email))
err = s.Pool.QueryRow(ctx, `
SELECT company_id, role
FROM memberships
WHERE user_id = $1 AND status = 'active'
ORDER BY created_at
LIMIT 1`, userID).Scan(&out.CompanyID, &out.Role)
if errors.Is(err, pgx.ErrNoRows) {
return SetPasswordInvite{}, ErrNotEligibleSetPassword
}
if err != nil {
return SetPasswordInvite{}, err
}
out.Role = normalizeInviteRole(out.Role)
token, err := RandomToken(24)
if err != nil {
return SetPasswordInvite{}, err
}
out.Token = token
out.ExpiresAt = time.Now().UTC().Add(ttl)
// Expire prior unaccepted invites for this email+company so re-issue is safe.
if _, err := s.Pool.Exec(ctx, `
UPDATE invites
SET expires_at = least(expires_at, now())
WHERE company_id = $1 AND lower(email) = lower($2) AND accepted_at IS NULL`,
out.CompanyID, out.Email); err != nil {
return SetPasswordInvite{}, err
}
err = s.Pool.QueryRow(ctx, `
INSERT INTO invites (company_id, email, role, token, expires_at)
VALUES ($1, $2, $3, $4, $5)
RETURNING id`,
out.CompanyID, out.Email, out.Role, HashInviteToken(token), out.ExpiresAt,
).Scan(&out.InviteID)
if err != nil {
return SetPasswordInvite{}, err
}
return out, nil
}