← back to Ventura Claw Leads

lib/auth.js

172 lines

// Business-side auth — bcrypt + session-based + claim tokens.
// Mirrors the NPH installer-side pattern but scoped to business_users.

const crypto = require('node:crypto');
const bcrypt = require('bcrypt');
const db = require('./db');

const BCRYPT_ROUNDS = 12;
const CLAIM_TTL_HOURS = 24;

async function hashPassword(plain) {
  return bcrypt.hash(plain, BCRYPT_ROUNDS);
}
async function verifyPassword(plain, hash) {
  if (!hash) return false;
  return bcrypt.compare(plain, hash);
}

function normalizeEmail(s) {
  return String(s || '').trim().toLowerCase();
}
function isEmailShape(s) {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s);
}

async function findUserByEmail(email) {
  return db.one(
    `SELECT id, email, password_hash, business_id, display_name, email_verified, last_login_at FROM business_users WHERE email = $1`,
    [normalizeEmail(email)]
  );
}

async function createUser({ email, businessId, passwordHash, displayName, emailVerified }) {
  const r = await db.one(
    `INSERT INTO business_users (email, password_hash, business_id, display_name, email_verified)
     VALUES ($1, $2, $3, $4, $5)
     ON CONFLICT (email) DO NOTHING
     RETURNING id, email, business_id, display_name, email_verified`,
    [normalizeEmail(email), passwordHash || null, businessId, displayName || null, !!emailVerified]
  );
  if (r) return r;
  // Conflict — pull the existing record (someone tried to re-claim).
  return findUserByEmail(email);
}

async function setPassword(userId, plain) {
  const hash = await hashPassword(plain);
  await db.query(`UPDATE business_users SET password_hash = $2 WHERE id = $1`, [userId, hash]);
}

async function recordLogin(userId) {
  await db.query(`UPDATE business_users SET last_login_at = now() WHERE id = $1`, [userId]);
}

// Session-based business auth. session.businessUserId is set on login.
async function attachBusiness(req, res, next) {
  res.locals.currentBusinessUser = null;
  res.locals.currentBusiness = null;
  if (!req.session || !req.session.businessUserId) return next();
  try {
    const user = await db.one(
      `SELECT bu.id, bu.email, bu.business_id, bu.display_name,
              b.slug, b.business_name, b.tier, b.subscription_status, b.stripe_customer_id, b.stripe_subscription_id
         FROM business_users bu
         JOIN businesses b ON b.id = bu.business_id
        WHERE bu.id = $1`,
      [req.session.businessUserId]
    );
    if (user) {
      res.locals.currentBusinessUser = { id: user.id, email: user.email, display_name: user.display_name };
      res.locals.currentBusiness = {
        id: user.business_id, slug: user.slug, business_name: user.business_name,
        tier: user.tier, subscription_status: user.subscription_status,
        stripe_customer_id: user.stripe_customer_id, stripe_subscription_id: user.stripe_subscription_id
      };
    } else {
      // Stale session — user was deleted; clear it.
      req.session.businessUserId = null;
    }
  } catch (e) {
    console.warn('[auth] attachBusiness error', e.message);
  }
  next();
}

function requireBusiness(req, res, next) {
  if (!res.locals.currentBusinessUser) {
    return res.redirect('/admin/login?next=' + encodeURIComponent(req.originalUrl));
  }
  next();
}

// ─── Claim tokens ──────────────────────────────────────────────────────
function claimSecret() {
  return process.env.CLAIM_SIGNING_SECRET || process.env.SESSION_SECRET || 'dev-only-not-for-prod';
}

async function issueClaimToken({ businessId, email, ipHash }) {
  const raw = `claim:${businessId}:${normalizeEmail(email)}:${Date.now()}:${crypto.randomBytes(8).toString('hex')}`;
  const token = crypto.createHmac('sha256', claimSecret()).update(raw).digest('base64url').slice(0, 32);
  const expiresAt = new Date(Date.now() + CLAIM_TTL_HOURS * 3600 * 1000).toISOString();
  await db.query(
    `INSERT INTO claim_tokens (token, business_id, email, expires_at, ip_hash)
     VALUES ($1, $2, $3, $4, $5)`,
    [token, businessId, normalizeEmail(email), expiresAt, ipHash || null]
  );
  return { token, expiresAt };
}

async function consumeClaimToken(token) {
  const row = await db.one(
    `SELECT ct.id, ct.business_id, ct.email, ct.consumed_at, ct.expires_at,
            b.slug, b.business_name, b.claim_status
       FROM claim_tokens ct
       JOIN businesses b ON b.id = ct.business_id
      WHERE ct.token = $1`,
    [token]
  );
  if (!row) return { ok: false, reason: 'invalid' };
  if (row.consumed_at) return { ok: false, reason: 'already_used' };
  if (new Date(row.expires_at) < new Date()) return { ok: false, reason: 'expired' };
  return { ok: true, claim: row };
}

async function markClaimConsumed(token) {
  await db.query(`UPDATE claim_tokens SET consumed_at = now() WHERE token = $1`, [token]);
}

// ─── Team invitations (multi-user per business) ────────────────────────
const INVITE_TTL_HOURS = 7 * 24;  // 1 week — invites can sit longer than claims

async function issueInviteToken({ businessId, email, invitedByUserId, ipHash }) {
  const raw = `invite:${businessId}:${normalizeEmail(email)}:${Date.now()}:${crypto.randomBytes(8).toString('hex')}`;
  const token = crypto.createHmac('sha256', claimSecret()).update(raw).digest('base64url').slice(0, 32);
  const expiresAt = new Date(Date.now() + INVITE_TTL_HOURS * 3600 * 1000).toISOString();
  await db.query(
    `INSERT INTO business_invitations (token, business_id, email, invited_by_user_id, expires_at, ip_hash)
     VALUES ($1, $2, $3, $4, $5, $6)`,
    [token, businessId, normalizeEmail(email), invitedByUserId, expiresAt, ipHash || null]
  );
  return { token, expiresAt };
}

async function consumeInviteToken(token) {
  const row = await db.one(
    `SELECT bi.id, bi.business_id, bi.email, bi.invited_by_user_id, bi.consumed_at, bi.expires_at,
            b.slug, b.business_name,
            inviter.email AS inviter_email, inviter.display_name AS inviter_name
       FROM business_invitations bi
       JOIN businesses b ON b.id = bi.business_id
       LEFT JOIN business_users inviter ON inviter.id = bi.invited_by_user_id
      WHERE bi.token = $1`,
    [token]
  );
  if (!row) return { ok: false, reason: 'invalid' };
  if (row.consumed_at) return { ok: false, reason: 'already_used' };
  if (new Date(row.expires_at) < new Date()) return { ok: false, reason: 'expired' };
  return { ok: true, invite: row };
}

async function markInviteConsumed(token) {
  await db.query(`UPDATE business_invitations SET consumed_at = now() WHERE token = $1`, [token]);
}

module.exports = {
  hashPassword, verifyPassword, normalizeEmail, isEmailShape,
  findUserByEmail, createUser, setPassword, recordLogin,
  attachBusiness, requireBusiness,
  issueClaimToken, consumeClaimToken, markClaimConsumed,
  issueInviteToken, consumeInviteToken, markInviteConsumed
};