2026-08-09 22:47:43 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 22:47:43 +02:00
|
|
|
// 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
|
|
|
|
|
}
|