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)) } // 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 }