[object Object]

← back to Ventura Claw Leads

v0.2 #3: admin/claim flow + lead inbox + profile editor + billing UI

cc3cfef61499801d02bcf2bb3fe6fb48f3d6e7dd · 2026-05-06 16:51:52 -0700 · Steve Abrams

CLAIM FLOW
- POST /business/:slug/claim — visitor on unclaimed profile submits email,
  we mint HMAC-signed token (24h TTL) + email magic link via Purelymail.
- GET /claim/:token — magic link lands on a 'set a password' form.
- POST /claim/:token — bcrypt-hashes password, creates business_users row,
  flips businesses.claim_status='self', logs the user in.
- claimLimiter: 5 req/hour/IP on the start endpoint.

ADMIN
- /admin/login + /admin/logout (bcrypt verify, session-based, loginLimiter
  10 req/15min).
- /admin dashboard with stats (total leads, last 30d, delivered, pending) +
  recent-5 leads + welcome flash on fresh claim.
- /admin/profile editor — every public field (name/headline/description/
  address/contact/socials).
- /admin/leads inbox — full lead table with delivery status.
- /admin/billing — pricing tiers from VCL_TIERS, upgrade/downgrade buttons
  hit /admin/billing/checkout (Stripe Checkout) + manage-subscription button
  hits /admin/billing/portal (Stripe Customer Portal). Real flow with the
  live keys from v0.2 #4.

INFRA
- lib/auth.js — bcrypt + claim-token mint/consume + attachBusiness +
  requireBusiness; res.locals.currentBusinessUser + currentBusiness exposed.
- lib/csrf.js — lifted from NPH; res.locals.csrfToken auto-set; safe-method
  pass-through; webhooks bypass; double-submit + timing-safe compare.
- 4 new routes mounted: auth, claim, admin (auth.requireBusiness gated).
- 6 new EJS: claim-result, claim-complete, admin/login, admin/dashboard,
  admin/profile, admin/leads, admin/billing.
- header partial: 'Business sign in' / 'Dashboard' link toggle.
- business.ejs unclaimed branch: inline claim form with email field.

DB
- migrations/004_business_users_and_claims.sql — business_users (FK to
  businesses, UNIQUE on email + (business_id,email)) and claim_tokens
  (token UNIQUE, FK to businesses, expires_at, consumed_at, ip_hash).

Live test 2026-05-06 on prod (cactus-and-cane-plants-tarzana):
  GET profile (csrf) → POST /business/:slug/claim (email submitted) →
  email-fetch token from DB → GET /claim/token (form) → POST /claim/token
  (password set, password_confirm matched) → claim_status flipped to
  'self' with claimed_by_email recorded → session-login automatic → all
  4 admin pages return 200 (dashboard / leads / profile / billing).

Closes v0.2 #3. Now the Stripe products from v0.2 #4 have a UI to be
purchased through; admin/billing/checkout invokes
stripe.createSubscriptionCheckout with the right price ID per tier.

Files touched

Diff

commit cc3cfef61499801d02bcf2bb3fe6fb48f3d6e7dd
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed May 6 16:51:52 2026 -0700

    v0.2 #3: admin/claim flow + lead inbox + profile editor + billing UI
    
    CLAIM FLOW
    - POST /business/:slug/claim — visitor on unclaimed profile submits email,
      we mint HMAC-signed token (24h TTL) + email magic link via Purelymail.
    - GET /claim/:token — magic link lands on a 'set a password' form.
    - POST /claim/:token — bcrypt-hashes password, creates business_users row,
      flips businesses.claim_status='self', logs the user in.
    - claimLimiter: 5 req/hour/IP on the start endpoint.
    
    ADMIN
    - /admin/login + /admin/logout (bcrypt verify, session-based, loginLimiter
      10 req/15min).
    - /admin dashboard with stats (total leads, last 30d, delivered, pending) +
      recent-5 leads + welcome flash on fresh claim.
    - /admin/profile editor — every public field (name/headline/description/
      address/contact/socials).
    - /admin/leads inbox — full lead table with delivery status.
    - /admin/billing — pricing tiers from VCL_TIERS, upgrade/downgrade buttons
      hit /admin/billing/checkout (Stripe Checkout) + manage-subscription button
      hits /admin/billing/portal (Stripe Customer Portal). Real flow with the
      live keys from v0.2 #4.
    
    INFRA
    - lib/auth.js — bcrypt + claim-token mint/consume + attachBusiness +
      requireBusiness; res.locals.currentBusinessUser + currentBusiness exposed.
    - lib/csrf.js — lifted from NPH; res.locals.csrfToken auto-set; safe-method
      pass-through; webhooks bypass; double-submit + timing-safe compare.
    - 4 new routes mounted: auth, claim, admin (auth.requireBusiness gated).
    - 6 new EJS: claim-result, claim-complete, admin/login, admin/dashboard,
      admin/profile, admin/leads, admin/billing.
    - header partial: 'Business sign in' / 'Dashboard' link toggle.
    - business.ejs unclaimed branch: inline claim form with email field.
    
    DB
    - migrations/004_business_users_and_claims.sql — business_users (FK to
      businesses, UNIQUE on email + (business_id,email)) and claim_tokens
      (token UNIQUE, FK to businesses, expires_at, consumed_at, ip_hash).
    
    Live test 2026-05-06 on prod (cactus-and-cane-plants-tarzana):
      GET profile (csrf) → POST /business/:slug/claim (email submitted) →
      email-fetch token from DB → GET /claim/token (form) → POST /claim/token
      (password set, password_confirm matched) → claim_status flipped to
      'self' with claimed_by_email recorded → session-login automatic → all
      4 admin pages return 200 (dashboard / leads / profile / billing).
    
    Closes v0.2 #3. Now the Stripe products from v0.2 #4 have a UI to be
    purchased through; admin/billing/checkout invokes
    stripe.createSubscriptionCheckout with the right price ID per tier.
---
 db/migrations/004_business_users_and_claims.sql |  30 +++++
 lib/auth.js                                     | 134 +++++++++++++++++++++
 lib/csrf.js                                     |  44 +++++++
 package-lock.json                               |  35 ++++++
 package.json                                    |   1 +
 routes/admin.js                                 | 136 +++++++++++++++++++++
 routes/auth.js                                  |  42 +++++++
 routes/claim.js                                 | 149 ++++++++++++++++++++++++
 server.js                                       |  25 +++-
 views/admin/billing.ejs                         |  87 ++++++++++++++
 views/admin/dashboard.ejs                       |  67 +++++++++++
 views/admin/leads.ejs                           |  35 ++++++
 views/admin/login.ejs                           |  24 ++++
 views/admin/profile.ejs                         |  39 +++++++
 views/partials/header.ejs                       |   5 +
 views/public/business.ejs                       |  16 ++-
 views/public/claim-complete.ejs                 |  33 ++++++
 views/public/claim-result.ejs                   |  19 +++
 18 files changed, 917 insertions(+), 4 deletions(-)

diff --git a/db/migrations/004_business_users_and_claims.sql b/db/migrations/004_business_users_and_claims.sql
new file mode 100644
index 0000000..92ec168
--- /dev/null
+++ b/db/migrations/004_business_users_and_claims.sql
@@ -0,0 +1,30 @@
+-- Admin/claim infrastructure for businesses to claim their directory listing,
+-- log into a dashboard, see leads, edit profile, and manage subscription.
+
+CREATE TABLE IF NOT EXISTS business_users (
+  id              BIGSERIAL PRIMARY KEY,
+  email           TEXT UNIQUE NOT NULL,
+  password_hash   TEXT,                                            -- nullable until first password set; magic-link login still works
+  business_id     BIGINT NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
+  display_name    TEXT,
+  email_verified  BOOLEAN NOT NULL DEFAULT false,
+  last_login_at   TIMESTAMPTZ,
+  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
+  UNIQUE (business_id, email)
+);
+CREATE INDEX IF NOT EXISTS business_users_business_idx ON business_users (business_id);
+
+-- One-shot claim tokens. The email→business_id mapping is in the row, so a
+-- compromised token only unlocks that one specific listing.
+CREATE TABLE IF NOT EXISTS claim_tokens (
+  id              BIGSERIAL PRIMARY KEY,
+  token           TEXT UNIQUE NOT NULL,                            -- HMAC-derived, 32 chars
+  business_id     BIGINT NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
+  email           TEXT NOT NULL,                                   -- the recipient who'll claim
+  issued_at       TIMESTAMPTZ NOT NULL DEFAULT now(),
+  expires_at      TIMESTAMPTZ NOT NULL,
+  consumed_at     TIMESTAMPTZ,
+  ip_hash         TEXT
+);
+CREATE INDEX IF NOT EXISTS claim_tokens_business_idx ON claim_tokens (business_id);
+CREATE INDEX IF NOT EXISTS claim_tokens_active_idx ON claim_tokens (token) WHERE consumed_at IS NULL;
diff --git a/lib/auth.js b/lib/auth.js
new file mode 100644
index 0000000..4344325
--- /dev/null
+++ b/lib/auth.js
@@ -0,0 +1,134 @@
+// 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]);
+}
+
+module.exports = {
+  hashPassword, verifyPassword, normalizeEmail, isEmailShape,
+  findUserByEmail, createUser, setPassword, recordLogin,
+  attachBusiness, requireBusiness,
+  issueClaimToken, consumeClaimToken, markClaimConsumed
+};
diff --git a/lib/csrf.js b/lib/csrf.js
new file mode 100644
index 0000000..0091dbf
--- /dev/null
+++ b/lib/csrf.js
@@ -0,0 +1,44 @@
+// CSRF — per-session token, double-submit pattern with timing-safe compare.
+// Lifted from NPH. Skips GET/HEAD/OPTIONS, /webhooks/* (Stripe sig handles auth),
+// and any req with `req.skipCsrf = true`.
+
+const crypto = require('crypto');
+
+function ensureToken(req) {
+  if (!req.session) return null;
+  if (!req.session.csrfToken) {
+    req.session.csrfToken = crypto.randomBytes(24).toString('base64url');
+  }
+  return req.session.csrfToken;
+}
+
+function csrfMiddleware(req, res, next) {
+  const token = ensureToken(req);
+  res.locals.csrfToken = token || '';
+
+  const safe = req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS';
+  if (safe) return next();
+  if (req.path.startsWith('/webhooks/')) return next();
+  if (req.skipCsrf) return next();
+
+  const supplied = (req.body && req.body._csrf) || req.get('x-csrf-token') || '';
+  if (!token || !supplied) return reject(req, res);
+
+  let a, b;
+  try { a = Buffer.from(token); b = Buffer.from(supplied); }
+  catch { return reject(req, res); }
+  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return reject(req, res);
+  next();
+}
+
+function reject(req, res) {
+  if (req.accepts('json') && !req.accepts('html')) {
+    return res.status(403).json({ error: 'csrf_failed' });
+  }
+  return res.status(403).render('public/error', {
+    title: 'Request blocked',
+    message: 'Form expired or session lost — please reload the page and try again.'
+  });
+}
+
+module.exports = { csrfMiddleware, ensureToken };
diff --git a/package-lock.json b/package-lock.json
index 33503f5..c90312e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,7 @@
       "name": "ventura-claw-leads",
       "version": "0.1.0",
       "dependencies": {
+        "bcrypt": "^6.0.0",
         "connect-pg-simple": "^9.0.1",
         "dotenv": "^16.4.5",
         "ejs": "^3.1.10",
@@ -72,6 +73,20 @@
       "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
       "license": "MIT"
     },
+    "node_modules/bcrypt": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
+      "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
+      "hasInstallScript": true,
+      "license": "MIT",
+      "dependencies": {
+        "node-addon-api": "^8.3.0",
+        "node-gyp-build": "^4.8.4"
+      },
+      "engines": {
+        "node": ">= 18"
+      }
+    },
     "node_modules/body-parser": {
       "version": "1.20.5",
       "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
@@ -764,6 +779,26 @@
         "node": ">= 0.6"
       }
     },
+    "node_modules/node-addon-api": {
+      "version": "8.7.0",
+      "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz",
+      "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==",
+      "license": "MIT",
+      "engines": {
+        "node": "^18 || ^20 || >= 21"
+      }
+    },
+    "node_modules/node-gyp-build": {
+      "version": "4.8.4",
+      "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
+      "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
+      "license": "MIT",
+      "bin": {
+        "node-gyp-build": "bin.js",
+        "node-gyp-build-optional": "optional.js",
+        "node-gyp-build-test": "build-test.js"
+      }
+    },
     "node_modules/nodemailer": {
       "version": "8.0.7",
       "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz",
diff --git a/package.json b/package.json
index 6ef1765..d954198 100644
--- a/package.json
+++ b/package.json
@@ -11,6 +11,7 @@
     "seed": "node scripts/seed-demo.js"
   },
   "dependencies": {
+    "bcrypt": "^6.0.0",
     "connect-pg-simple": "^9.0.1",
     "dotenv": "^16.4.5",
     "ejs": "^3.1.10",
diff --git a/routes/admin.js b/routes/admin.js
new file mode 100644
index 0000000..f9399fd
--- /dev/null
+++ b/routes/admin.js
@@ -0,0 +1,136 @@
+const express = require('express');
+const slugify = require('slugify');
+const db = require('../lib/db');
+const auth = require('../lib/auth');
+const stripe = require('../lib/stripe');
+const router = express.Router();
+
+const PUBLIC_URL = (process.env.PUBLIC_URL || 'https://leads.venturaclaw.com').replace(/\/+$/, '');
+
+router.use(auth.requireBusiness);
+
+router.get('/', async (req, res, next) => {
+  try {
+    const biz = res.locals.currentBusiness;
+    const stats = await db.one(`
+      SELECT
+        COUNT(*)::int AS total_leads,
+        COUNT(*) FILTER (WHERE delivered_at IS NOT NULL)::int AS delivered,
+        COUNT(*) FILTER (WHERE delivered_at IS NULL)::int AS pending,
+        COUNT(*) FILTER (WHERE created_at > now() - interval '30 days')::int AS last_30d
+      FROM business_interest WHERE business_id = $1
+    `, [biz.id]);
+    const recent = await db.many(`
+      SELECT id, consumer_name, consumer_email, consumer_phone, message, zip,
+             created_at, delivered_at, delivery_email
+        FROM business_interest WHERE business_id = $1
+       ORDER BY created_at DESC LIMIT 5
+    `, [biz.id]);
+    res.render('admin/dashboard', {
+      title: `${biz.business_name} · Dashboard`,
+      stats, recent,
+      welcome: req.query.welcome === '1'
+    });
+  } catch (err) { next(err); }
+});
+
+router.get('/profile', async (req, res, next) => {
+  try {
+    const biz = await db.one(`SELECT * FROM businesses WHERE id = $1`, [res.locals.currentBusiness.id]);
+    res.render('admin/profile', { title: 'Edit profile · Ventura Claw', biz, error: null, ok: req.query.saved === '1' });
+  } catch (err) { next(err); }
+});
+
+router.post('/profile', async (req, res, next) => {
+  try {
+    const biz = res.locals.currentBusiness;
+    const fields = {
+      business_name:   String(req.body.business_name || '').trim().slice(0, 200),
+      headline:        String(req.body.headline || '').trim().slice(0, 250) || null,
+      description:     String(req.body.description || '').trim().slice(0, 4000) || null,
+      street:          String(req.body.street || '').trim().slice(0, 200) || null,
+      neighborhood:    String(req.body.neighborhood || '').trim().slice(0, 80) || null,
+      city:            String(req.body.city || '').trim().slice(0, 80) || null,
+      zip:             String(req.body.zip || '').trim().slice(0, 10) || null,
+      phone:           String(req.body.phone || '').trim().slice(0, 32) || null,
+      email:           String(req.body.email || '').trim().toLowerCase().slice(0, 200) || null,
+      website:         String(req.body.website || '').trim().slice(0, 300) || null,
+      instagram_handle: String(req.body.instagram_handle || '').trim().replace(/^@/, '').slice(0, 60) || null
+    };
+    if (!fields.business_name) {
+      const fresh = await db.one(`SELECT * FROM businesses WHERE id = $1`, [biz.id]);
+      return res.status(400).render('admin/profile', {
+        title: 'Edit profile · Ventura Claw', biz: fresh, ok: false,
+        error: 'Business name is required.'
+      });
+    }
+    await db.query(`
+      UPDATE businesses SET
+        business_name = $2, headline = $3, description = $4,
+        street = $5, neighborhood = $6, city = $7, zip = $8,
+        phone = $9, email = $10, website = $11, instagram_handle = $12
+      WHERE id = $1
+    `, [biz.id, fields.business_name, fields.headline, fields.description,
+        fields.street, fields.neighborhood, fields.city, fields.zip,
+        fields.phone, fields.email, fields.website, fields.instagram_handle]);
+    res.redirect('/admin/profile?saved=1');
+  } catch (err) { next(err); }
+});
+
+router.get('/leads', async (req, res, next) => {
+  try {
+    const bizId = res.locals.currentBusiness.id;
+    const leads = await db.many(`
+      SELECT id, consumer_name, consumer_email, consumer_phone, message, zip, source,
+             created_at, delivered_at, delivery_email, delivery_msg_id, bill_amount_cents
+        FROM business_interest
+       WHERE business_id = $1
+       ORDER BY created_at DESC
+       LIMIT 200
+    `, [bizId]);
+    res.render('admin/leads', { title: 'Leads · Ventura Claw', leads });
+  } catch (err) { next(err); }
+});
+
+// ─── Billing ───────────────────────────────────────────────────────────
+router.get('/billing', (req, res) => {
+  res.render('admin/billing', {
+    title: 'Billing · Ventura Claw',
+    stripeLive: stripe.isLive(),
+    tiers: stripe.VCL_TIERS,
+    flash: req.query
+  });
+});
+
+router.post('/billing/checkout', async (req, res, next) => {
+  try {
+    const tier = String(req.body.tier || '').trim();
+    if (!stripe.VCL_TIERS[tier]) {
+      return res.redirect('/admin/billing?error=unknown_tier');
+    }
+    const biz = await db.one(`SELECT id, slug, business_name, email, stripe_customer_id FROM businesses WHERE id = $1`, [res.locals.currentBusiness.id]);
+    const session = await stripe.createSubscriptionCheckout({
+      business: biz, tier,
+      successUrl: `${PUBLIC_URL}/admin/billing?upgraded=${tier}`,
+      cancelUrl:  `${PUBLIC_URL}/admin/billing?canceled=1`
+    });
+    if (session.mocked) {
+      return res.redirect(session.url || '/admin/billing?mock=1');
+    }
+    res.redirect(303, session.url);
+  } catch (err) { next(err); }
+});
+
+router.post('/billing/portal', async (req, res, next) => {
+  try {
+    const biz = await db.one(`SELECT id, slug, business_name, email, stripe_customer_id FROM businesses WHERE id = $1`, [res.locals.currentBusiness.id]);
+    if (!biz.stripe_customer_id) {
+      return res.redirect('/admin/billing?error=no_customer');
+    }
+    const portal = await stripe.createPortalSession({ business: biz, returnUrl: `${PUBLIC_URL}/admin/billing` });
+    if (portal.mocked) return res.redirect('/admin/billing?mock=portal');
+    res.redirect(303, portal.url);
+  } catch (err) { next(err); }
+});
+
+module.exports = router;
diff --git a/routes/auth.js b/routes/auth.js
new file mode 100644
index 0000000..0aceda0
--- /dev/null
+++ b/routes/auth.js
@@ -0,0 +1,42 @@
+const express = require('express');
+const auth = require('../lib/auth');
+const router = express.Router();
+
+router.get('/admin/login', (req, res) => {
+  if (res.locals.currentBusinessUser) return res.redirect('/admin');
+  res.render('admin/login', {
+    title: 'Sign in · Ventura Claw',
+    next: String(req.query.next || '/admin'),
+    error: null,
+    email: ''
+  });
+});
+
+router.post('/admin/login', async (req, res) => {
+  const email = auth.normalizeEmail(req.body.email);
+  const password = String(req.body.password || '');
+  const next = String(req.body.next || req.query.next || '/admin');
+
+  if (!auth.isEmailShape(email) || !password) {
+    return res.status(400).render('admin/login', {
+      title: 'Sign in · Ventura Claw', next, email,
+      error: 'Email and password required.'
+    });
+  }
+  const user = await auth.findUserByEmail(email);
+  if (!user || !user.password_hash || !(await auth.verifyPassword(password, user.password_hash))) {
+    return res.status(400).render('admin/login', {
+      title: 'Sign in · Ventura Claw', next, email,
+      error: 'Email or password incorrect. If you just claimed your listing, check your inbox for the link.'
+    });
+  }
+  req.session.businessUserId = user.id;
+  await auth.recordLogin(user.id);
+  res.redirect(next.startsWith('/admin') ? next : '/admin');
+});
+
+router.post('/admin/logout', (req, res) => {
+  req.session.destroy(() => res.redirect('/'));
+});
+
+module.exports = router;
diff --git a/routes/claim.js b/routes/claim.js
new file mode 100644
index 0000000..1cfb72f
--- /dev/null
+++ b/routes/claim.js
@@ -0,0 +1,149 @@
+const express = require('express');
+const crypto = require('node:crypto');
+const db = require('../lib/db');
+const auth = require('../lib/auth');
+const compliance = require('../lib/compliance');
+const { sendEmail } = require('../lib/email');
+const router = express.Router();
+
+const PUBLIC_URL = (process.env.PUBLIC_URL || 'https://leads.venturaclaw.com').replace(/\/+$/, '');
+
+// Step 1: visitor on a business profile clicks "Claim this listing" → enters
+// the email they want to claim with. We email them a magic link.
+router.post('/business/:slug/claim', async (req, res, next) => {
+  try {
+    const biz = await db.one(`SELECT id, slug, business_name, claim_status, email AS biz_email FROM businesses WHERE slug = $1`, [req.params.slug]);
+    if (!biz) return res.status(404).render('public/404', { title: 'Not Found' });
+
+    const submittedEmail = auth.normalizeEmail(req.body.email);
+    if (!auth.isEmailShape(submittedEmail)) {
+      return res.status(400).render('public/claim-result', {
+        title: 'Email needed', business: biz, ok: false,
+        error: 'Please enter a valid email address.'
+      });
+    }
+
+    const ipHash = crypto.createHash('sha256')
+      .update((req.headers['x-forwarded-for'] || req.ip || '') + (process.env.SESSION_SECRET || ''))
+      .digest('hex').slice(0, 16);
+
+    const { token } = await auth.issueClaimToken({ businessId: biz.id, email: submittedEmail, ipHash });
+    const link = `${PUBLIC_URL}/claim/${encodeURIComponent(token)}`;
+    const profileUrl = `${PUBLIC_URL}/business/${encodeURIComponent(biz.slug)}`;
+
+    // Compliance: if MAILING_ADDRESS / suppression tables aren't ready, this
+    // would assert-fail and we'd never email. For dev we skip the gate.
+    if (process.env.NODE_ENV === 'production') {
+      try { await compliance.assertSendCompliance({ campaign: 'claim_invite' }); }
+      catch (e) {
+        console.warn('[claim] compliance fail-closed', e.code);
+        return res.status(503).render('public/claim-result', { title: 'Try again later', business: biz, ok: false, error: 'Email service is not configured. Reach out at info@venturaclaw.com.' });
+      }
+    }
+
+    const subject = `Claim ${biz.business_name} on Ventura Claw`;
+    const html = `<div style="font-family:Georgia,serif;max-width:560px;margin:0 auto;padding:32px 24px;color:#0e0e0e">
+  <p style="font-size:11px;letter-spacing:0.16em;text-transform:uppercase;color:#b8860b;margin:0 0 8px">Claim your listing</p>
+  <h1 style="font-size:24px;font-weight:400;margin:0 0 12px">Confirm you own ${escapeHtml(biz.business_name)}</h1>
+  <p style="font-size:15px;line-height:1.55">Click the link below to claim the public listing for <strong>${escapeHtml(biz.business_name)}</strong> on Ventura Claw. The link is valid for 24 hours and works once.</p>
+  <p style="margin:24px 0"><a href="${link}" style="display:inline-block;background:#0e0e0e;color:#fff;padding:14px 22px;text-decoration:none;font-size:14px;letter-spacing:0.04em;border-radius:2px">Claim ${escapeHtml(biz.business_name)} →</a></p>
+  <p style="font-size:13px;color:#666;line-height:1.55">Profile: <a href="${profileUrl}" style="color:#666">${profileUrl.replace(/^https?:\/\//, '')}</a></p>
+  <p style="font-size:12px;color:#888;margin:24px 0 0;line-height:1.55">If you didn't request this, ignore the message — no action is taken until the link is clicked.</p>
+${compliance.complianceFooter({ campaign: 'claim_invite', unsubscribeUrl: null })}
+</div>`;
+    const text = `Claim ${biz.business_name} on Ventura Claw.\n\n${link}\n\n(link expires in 24 hours, single use)`;
+
+    const result = await sendEmail({ to: submittedEmail, subject, html, text });
+    if (process.env.NODE_ENV === 'production') {
+      await compliance.recordAudit({
+        channel: 'email', campaign: 'claim_invite',
+        recipient: submittedEmail, businessId: biz.id,
+        decision: result.ok ? 'sent' : 'failed',
+        reason: result.ok ? null : (result.error || 'unknown'),
+        subject, messageId: result.messageId || null,
+        payload: { token_prefix: token.slice(0, 8) }
+      });
+    }
+
+    res.render('public/claim-result', {
+      title: 'Check your email', business: biz, ok: true, email: submittedEmail
+    });
+  } catch (err) { next(err); }
+});
+
+// Step 2: visitor clicks the magic link from their email → set password →
+// session-login.
+router.get('/claim/:token', async (req, res, next) => {
+  try {
+    const r = await auth.consumeClaimToken(req.params.token);
+    if (!r.ok) {
+      return res.status(400).render('public/claim-complete', {
+        title: 'Link not valid',
+        business: null, ok: false, reason: r.reason
+      });
+    }
+    res.render('public/claim-complete', {
+      title: `Set up your account · ${r.claim.business_name}`,
+      business: { id: r.claim.business_id, slug: r.claim.slug, business_name: r.claim.business_name },
+      ok: true, token: req.params.token, email: r.claim.email, error: null
+    });
+  } catch (err) { next(err); }
+});
+
+router.post('/claim/:token', async (req, res, next) => {
+  try {
+    const r = await auth.consumeClaimToken(req.params.token);
+    if (!r.ok) {
+      return res.status(400).render('public/claim-complete', {
+        title: 'Link not valid', business: null, ok: false, reason: r.reason
+      });
+    }
+    const password = String(req.body.password || '');
+    const passwordConfirm = String(req.body.password_confirm || '');
+    if (password.length < 10) {
+      return res.status(400).render('public/claim-complete', {
+        title: 'Password too short',
+        business: { id: r.claim.business_id, slug: r.claim.slug, business_name: r.claim.business_name },
+        ok: true, token: req.params.token, email: r.claim.email,
+        error: 'Pick a password with at least 10 characters.'
+      });
+    }
+    if (password !== passwordConfirm) {
+      return res.status(400).render('public/claim-complete', {
+        title: 'Passwords don\'t match',
+        business: { id: r.claim.business_id, slug: r.claim.slug, business_name: r.claim.business_name },
+        ok: true, token: req.params.token, email: r.claim.email,
+        error: 'The two password fields must match.'
+      });
+    }
+
+    // Mark token consumed first to prevent replay during the rest of the flow.
+    await auth.markClaimConsumed(req.params.token);
+
+    const passwordHash = await auth.hashPassword(password);
+    const user = await auth.createUser({
+      email: r.claim.email, businessId: r.claim.business_id,
+      passwordHash, displayName: r.claim.email.split('@')[0],
+      emailVerified: true
+    });
+    if (user && !user.password_hash) {
+      // Pre-existing user without a password — set it now.
+      await auth.setPassword(user.id, password);
+    }
+    // Flip business to claimed.
+    await db.query(
+      `UPDATE businesses SET claim_status = 'self', claimed_by_email = $2, claimed_at = now() WHERE id = $1`,
+      [r.claim.business_id, r.claim.email]
+    );
+
+    req.session.businessUserId = user.id;
+    await auth.recordLogin(user.id);
+    res.redirect('/admin?welcome=1');
+  } catch (err) { next(err); }
+});
+
+function escapeHtml(s) {
+  return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
+}
+
+module.exports = router;
diff --git a/server.js b/server.js
index ae2029b..5a6b27f 100644
--- a/server.js
+++ b/server.js
@@ -9,8 +9,13 @@ const PgSession = require('connect-pg-simple')(session);
 const rateLimit = require('express-rate-limit');
 
 const db = require('./lib/db');
+const auth = require('./lib/auth');
+const { csrfMiddleware } = require('./lib/csrf');
 const publicRoutes = require('./routes/public');
 const webhookRoutes = require('./routes/webhooks');
+const authRoutes = require('./routes/auth');
+const claimRoutes = require('./routes/claim');
+const adminRoutes = require('./routes/admin');
 
 const app = express();
 const PORT = parseInt(process.env.PORT || '9789', 10);
@@ -69,18 +74,36 @@ app.use('/img',    express.static(path.join(__dirname, 'public/img'),  { maxAge:
 app.use((req, res, next) => {
   res.locals.publicUrl = process.env.PUBLIC_URL || `http://localhost:${PORT}`;
   res.locals.path = req.path;
-  res.locals.csrfToken = ''; // v0.1 — no CSRF middleware yet (read-only public routes only); add in v0.2 with admin
   next();
 });
 
+// Auth + CSRF (CSRF runs after attachBusiness so locals are available in error templates).
+app.use(auth.attachBusiness);
+app.use(csrfMiddleware);
+
 const contactLimiter = rateLimit({
   windowMs: 60 * 60 * 1000, max: 8,
   standardHeaders: true, legacyHeaders: false,
   message: { error: 'too_many_requests' }
 });
+const claimLimiter = rateLimit({
+  windowMs: 60 * 60 * 1000, max: 5,
+  standardHeaders: true, legacyHeaders: false,
+  message: { error: 'too_many_requests' }
+});
+const loginLimiter = rateLimit({
+  windowMs: 15 * 60 * 1000, max: 10,
+  standardHeaders: true, legacyHeaders: false,
+  message: { error: 'too_many_requests' }
+});
 app.use('/business/:slug/contact', contactLimiter);
+app.use('/business/:slug/claim', claimLimiter);
+app.use('/admin/login', loginLimiter);
 
 app.use('/', publicRoutes);
+app.use('/', authRoutes);
+app.use('/', claimRoutes);
+app.use('/admin', adminRoutes);
 
 app.use((req, res) => {
   res.status(404).render('public/404', { title: 'Not Found' });
diff --git a/views/admin/billing.ejs b/views/admin/billing.ejs
new file mode 100644
index 0000000..2cf8716
--- /dev/null
+++ b/views/admin/billing.ejs
@@ -0,0 +1,87 @@
+<%- include('../partials/head', { title }) %>
+<%- include('../partials/header') %>
+
+<section class="long-form" style="max-width:1000px">
+  <p class="kicker">Billing</p>
+  <h1 class="display-sm">Pick a tier.</h1>
+  <p class="lede">Current tier: <strong><%= currentBusiness.tier === 'free' ? 'Free' : currentBusiness.tier.charAt(0).toUpperCase() + currentBusiness.tier.slice(1) %></strong> · status: <%= currentBusiness.subscription_status || 'inactive' %></p>
+
+  <% if (flash.upgraded) { %><p class="callout" style="background:#dcfce7;border-left:3px solid #16a34a;color:#166534;padding:12px 16px;margin:16px 0;border-radius:4px">Subscription updated to <strong><%= flash.upgraded %></strong>. The new tier may take ~30 seconds to reflect on your dashboard once Stripe confirms.</p><% } %>
+  <% if (flash.canceled) { %><p class="callout" style="background:var(--bg-alt);border-left:3px solid var(--brass);padding:12px 16px;margin:16px 0;border-radius:4px">Checkout canceled — no changes made.</p><% } %>
+  <% if (flash.error) { %><p class="callout" style="background:#fee2e2;border-left:3px solid #dc2626;padding:12px 16px;margin:16px 0;border-radius:4px">Error: <%= flash.error %></p><% } %>
+  <% if (flash.mock || flash['mock=portal']) { %><p class="callout" style="background:var(--bg-alt);border-left:3px solid var(--brass);padding:12px 16px;margin:16px 0;border-radius:4px">Stripe is in mock mode (no live keys). Real checkout requires a configured Stripe account.</p><% } %>
+
+  <% if (!stripeLive) { %>
+    <p class="muted" style="margin-top:24px">Billing is in mock mode in this environment — buttons below are clickable but won't actually charge.</p>
+  <% } %>
+
+  <div class="tiers" style="margin-top:32px">
+    <div class="tier-card">
+      <h3>Free</h3>
+      <p class="tier-price">$0<small> / forever</small></p>
+      <ul>
+        <li>Public directory profile</li>
+        <li>Customer messages forwarded</li>
+        <li>Standard search placement</li>
+      </ul>
+      <% if (currentBusiness.tier === 'free') { %>
+        <p class="muted" style="font-size:12px;margin-top:14px">Your current tier.</p>
+      <% } %>
+    </div>
+
+    <% Object.entries(tiers).forEach(function(entry){
+         var tierKey = entry[0]; var tierMeta = entry[1];
+         var isCurrent = currentBusiness.tier === tierKey;
+         var isFeatured = tierKey === 'standard';
+    %>
+      <div class="tier-card<%= isFeatured ? ' is-featured' : '' %>">
+        <h3><%= tierMeta.name %></h3>
+        <p class="tier-price"><%= tierMeta.label.split(' /')[0] %><small> / month</small></p>
+        <ul>
+          <% if (tierKey === 'starter') { %>
+            <li>Custom description &amp; photos</li>
+            <li>"Claimed" badge in search</li>
+            <li>Priority over free listings</li>
+            <li>Monthly traffic report</li>
+          <% } else if (tierKey === 'standard') { %>
+            <li>Everything in Starter</li>
+            <li>"Standard" badge — above free listings</li>
+            <li>Featured in category sidebar</li>
+            <li>Sub-60s lead delivery</li>
+          <% } else { %>
+            <li>Everything in Standard</li>
+            <li>Home-page weekly rotation</li>
+            <li>Top placement in category</li>
+            <li>Quarterly co-marketing</li>
+          <% } %>
+        </ul>
+        <% if (isCurrent) { %>
+          <p class="muted" style="font-size:12px;margin-top:14px;color:#16a34a">Your current tier.</p>
+        <% } else { %>
+          <form method="post" action="/admin/billing/checkout" style="margin-top:14px">
+            <input type="hidden" name="_csrf" value="<%= csrfToken %>">
+            <input type="hidden" name="tier" value="<%= tierKey %>">
+            <button type="submit" class="btn btn-primary" style="width:100%">Upgrade to <%= tierMeta.name %></button>
+          </form>
+        <% } %>
+      </div>
+    <% }); %>
+  </div>
+
+  <% if (currentBusiness.stripe_customer_id) { %>
+    <h2 style="margin-top:48px">Manage subscription</h2>
+    <p>Update your payment method, see invoices, or cancel from the secure Stripe portal.</p>
+    <form method="post" action="/admin/billing/portal" style="margin-top:12px">
+      <input type="hidden" name="_csrf" value="<%= csrfToken %>">
+      <button type="submit" class="btn btn-ghost">Open billing portal →</button>
+    </form>
+  <% } %>
+
+  <h2 style="margin-top:48px">Sign out</h2>
+  <form method="post" action="/admin/logout" style="margin-top:8px">
+    <input type="hidden" name="_csrf" value="<%= csrfToken %>">
+    <button type="submit" class="btn btn-ghost btn-sm">Sign out</button>
+  </form>
+</section>
+
+<%- include('../partials/footer') %>
diff --git a/views/admin/dashboard.ejs b/views/admin/dashboard.ejs
new file mode 100644
index 0000000..a62e928
--- /dev/null
+++ b/views/admin/dashboard.ejs
@@ -0,0 +1,67 @@
+<%- include('../partials/head', { title }) %>
+<%- include('../partials/header') %>
+
+<section class="long-form" style="max-width:1100px">
+  <% if (welcome) { %>
+    <div class="callout" style="background:#dcfce7;border-left:3px solid #16a34a;color:#166534;padding:14px 18px;margin:0 0 24px;border-radius:4px">
+      <strong>Welcome to Ventura Claw.</strong> Your listing is now claimed. Edit your profile and pick a tier to unlock featured placement.
+    </div>
+  <% } %>
+
+  <p class="kicker">Dashboard</p>
+  <h1 class="display-sm"><%= currentBusiness.business_name %></h1>
+  <p class="lede">Tier: <strong><%= currentBusiness.tier === 'free' ? 'Free' : (currentBusiness.tier.charAt(0).toUpperCase() + currentBusiness.tier.slice(1)) %></strong> · Subscription: <strong><%= currentBusiness.subscription_status || 'inactive' %></strong></p>
+
+  <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:14px;margin:32px 0">
+    <div class="side-card" style="text-align:center">
+      <h3>Total leads</h3>
+      <p style="font-family:var(--serif);font-size:36px;color:var(--brass);margin:0"><%= stats.total_leads %></p>
+    </div>
+    <div class="side-card" style="text-align:center">
+      <h3>Last 30 days</h3>
+      <p style="font-family:var(--serif);font-size:36px;color:var(--brass);margin:0"><%= stats.last_30d %></p>
+    </div>
+    <div class="side-card" style="text-align:center">
+      <h3>Delivered</h3>
+      <p style="font-family:var(--serif);font-size:36px;color:var(--brass);margin:0"><%= stats.delivered %></p>
+    </div>
+    <div class="side-card" style="text-align:center">
+      <h3>Pending</h3>
+      <p style="font-family:var(--serif);font-size:36px;color:var(--brass);margin:0"><%= stats.pending %></p>
+    </div>
+  </div>
+
+  <h2>Recent leads</h2>
+  <% if (recent.length === 0) { %>
+    <p class="muted">No leads yet — your listing is live; consumers will start finding you. Make sure your <a href="/admin/profile">profile</a> is filled out.</p>
+  <% } else { %>
+    <table style="width:100%;border-collapse:collapse;margin:16px 0">
+      <thead>
+        <tr style="border-bottom:1px solid var(--border);text-align:left">
+          <th style="padding:8px 0;font-size:11px;letter-spacing:0.08em;text-transform:uppercase;color:var(--fg-muted);font-weight:500">When</th>
+          <th style="padding:8px 0;font-size:11px;letter-spacing:0.08em;text-transform:uppercase;color:var(--fg-muted);font-weight:500">From</th>
+          <th style="padding:8px 0;font-size:11px;letter-spacing:0.08em;text-transform:uppercase;color:var(--fg-muted);font-weight:500">Status</th>
+        </tr>
+      </thead>
+      <tbody>
+        <% recent.forEach(function(l){ %>
+          <tr style="border-bottom:1px solid var(--border)">
+            <td style="padding:10px 0;font-size:13px;color:var(--fg-muted)"><%= new Date(l.created_at).toLocaleString('en-US',{ dateStyle:'medium', timeStyle:'short' }) %></td>
+            <td style="padding:10px 0;font-size:13px"><strong><%= l.consumer_name %></strong> · <%= l.consumer_email %></td>
+            <td style="padding:10px 0;font-size:13px"><%= l.delivered_at ? '<span style="color:#16a34a">Delivered</span>' : '<span style="color:var(--fg-muted)">Pending</span>' %></td>
+          </tr>
+        <% }); %>
+      </tbody>
+    </table>
+    <p style="margin-top:16px"><a href="/admin/leads" class="btn btn-ghost btn-sm">See all leads →</a></p>
+  <% } %>
+
+  <h2 style="margin-top:48px">Next steps</h2>
+  <ul>
+    <li><a href="/admin/profile">Edit your public profile</a> — make sure description, hours, contact, and Instagram are current.</li>
+    <li><a href="/admin/billing">Pick a tier</a> — Free is fine; paid tiers unlock featured placement and category-sidebar feature.</li>
+    <li><a href="/business/<%= currentBusiness.slug %>" target="_blank">View your live profile ↗</a></li>
+  </ul>
+</section>
+
+<%- include('../partials/footer') %>
diff --git a/views/admin/leads.ejs b/views/admin/leads.ejs
new file mode 100644
index 0000000..bdcd2be
--- /dev/null
+++ b/views/admin/leads.ejs
@@ -0,0 +1,35 @@
+<%- include('../partials/head', { title }) %>
+<%- include('../partials/header') %>
+
+<section class="long-form" style="max-width:1100px">
+  <p class="kicker">Leads</p>
+  <h1 class="display-sm">Inbox</h1>
+  <p class="lede">Every customer message routed to <%= currentBusiness.business_name %>. Replies via your email client go straight to the customer.</p>
+
+  <% if (leads.length === 0) { %>
+    <p class="muted" style="margin-top:32px">No leads yet. Make sure your <a href="/admin/profile">profile</a> is filled out — the more complete the listing, the more inbound.</p>
+  <% } else { %>
+    <table style="width:100%;border-collapse:collapse;margin:24px 0;font-size:13px">
+      <thead>
+        <tr style="border-bottom:2px solid var(--fg);text-align:left">
+          <th style="padding:10px 8px;font-size:11px;letter-spacing:0.08em;text-transform:uppercase;color:var(--fg-muted);font-weight:500">When</th>
+          <th style="padding:10px 8px;font-size:11px;letter-spacing:0.08em;text-transform:uppercase;color:var(--fg-muted);font-weight:500">From</th>
+          <th style="padding:10px 8px;font-size:11px;letter-spacing:0.08em;text-transform:uppercase;color:var(--fg-muted);font-weight:500">Message</th>
+          <th style="padding:10px 8px;font-size:11px;letter-spacing:0.08em;text-transform:uppercase;color:var(--fg-muted);font-weight:500">Status</th>
+        </tr>
+      </thead>
+      <tbody>
+        <% leads.forEach(function(l){ %>
+          <tr style="border-bottom:1px solid var(--border);vertical-align:top">
+            <td style="padding:12px 8px;color:var(--fg-muted);white-space:nowrap"><%= new Date(l.created_at).toLocaleString('en-US',{ month:'short', day:'numeric', hour:'numeric', minute:'2-digit' }) %></td>
+            <td style="padding:12px 8px"><strong><%= l.consumer_name %></strong><br><a href="mailto:<%= l.consumer_email %>"><%= l.consumer_email %></a><% if (l.consumer_phone) { %><br><a href="tel:<%= l.consumer_phone %>"><%= l.consumer_phone %></a><% } %><% if (l.zip) { %><br><span style="color:var(--fg-muted)">ZIP <%= l.zip %></span><% } %></td>
+            <td style="padding:12px 8px;max-width:380px"><%= l.message || '—' %></td>
+            <td style="padding:12px 8px"><% if (l.delivered_at) { %><span style="color:#16a34a">Delivered</span><br><span style="color:var(--fg-muted);font-size:11px"><%= new Date(l.delivered_at).toLocaleString('en-US',{ month:'short', day:'numeric', hour:'numeric', minute:'2-digit' }) %></span><% } else { %><span style="color:var(--fg-muted)">Pending (≤5min)</span><% } %></td>
+          </tr>
+        <% }); %>
+      </tbody>
+    </table>
+  <% } %>
+</section>
+
+<%- include('../partials/footer') %>
diff --git a/views/admin/login.ejs b/views/admin/login.ejs
new file mode 100644
index 0000000..693c9e3
--- /dev/null
+++ b/views/admin/login.ejs
@@ -0,0 +1,24 @@
+<%- include('../partials/head', { title }) %>
+<%- include('../partials/header') %>
+
+<section class="long-form" style="max-width:420px">
+  <p class="kicker">Business sign in</p>
+  <h1 class="display-sm">Sign in to your dashboard.</h1>
+  <p class="lede">Manage your listing, see leads, and update your subscription.</p>
+
+  <% if (error) { %>
+    <p class="callout" style="background:#fee2e2;border-left:3px solid #dc2626;padding:12px 16px;margin:16px 0;border-radius:4px"><%= error %></p>
+  <% } %>
+
+  <form method="post" action="/admin/login" style="margin-top:24px">
+    <input type="hidden" name="_csrf" value="<%= csrfToken %>">
+    <input type="hidden" name="next" value="<%= next %>">
+    <label>Email<input type="email" name="email" required value="<%= email %>" autocomplete="username"></label>
+    <label>Password<input type="password" name="password" required autocomplete="current-password"></label>
+    <button type="submit" class="btn btn-primary btn-lg" style="margin-top:8px">Sign in</button>
+  </form>
+
+  <p class="muted" style="margin-top:24px;font-size:13px">Haven't claimed your listing yet? Find your business in the <a href="/find">directory</a> and click "Claim this listing" on the profile page.</p>
+</section>
+
+<%- include('../partials/footer') %>
diff --git a/views/admin/profile.ejs b/views/admin/profile.ejs
new file mode 100644
index 0000000..7cb97d2
--- /dev/null
+++ b/views/admin/profile.ejs
@@ -0,0 +1,39 @@
+<%- include('../partials/head', { title }) %>
+<%- include('../partials/header') %>
+
+<section class="long-form" style="max-width:720px">
+  <p class="kicker">Profile</p>
+  <h1 class="display-sm">Edit your listing.</h1>
+  <p class="lede">Changes go live immediately on your public profile at <a href="/business/<%= biz.slug %>" target="_blank">/business/<%= biz.slug %></a>.</p>
+
+  <% if (ok) { %><p class="callout" style="background:#dcfce7;border-left:3px solid #16a34a;color:#166534;padding:12px 16px;margin:16px 0;border-radius:4px">Profile saved.</p><% } %>
+  <% if (error) { %><p class="callout" style="background:#fee2e2;border-left:3px solid #dc2626;padding:12px 16px;margin:16px 0;border-radius:4px"><%= error %></p><% } %>
+
+  <form method="post" action="/admin/profile" style="margin-top:24px">
+    <input type="hidden" name="_csrf" value="<%= csrfToken %>">
+
+    <label>Business name<input type="text" name="business_name" required maxlength="200" value="<%= biz.business_name %>"></label>
+    <label>Headline (1 line)<input type="text" name="headline" maxlength="250" value="<%= biz.headline || '' %>" placeholder="e.g. Wood-fired Italian, family-run since 1998"></label>
+    <label>Description<textarea name="description" rows="5" maxlength="4000"><%= biz.description || '' %></textarea></label>
+
+    <h3 style="margin-top:24px">Address</h3>
+    <label>Street<input type="text" name="street" maxlength="200" value="<%= biz.street || '' %>"></label>
+    <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
+      <label>Neighborhood<input type="text" name="neighborhood" maxlength="80" value="<%= biz.neighborhood || '' %>"></label>
+      <label>City<input type="text" name="city" maxlength="80" value="<%= biz.city || '' %>"></label>
+    </div>
+    <label>ZIP<input type="text" name="zip" maxlength="10" value="<%= biz.zip || '' %>"></label>
+
+    <h3 style="margin-top:24px">Contact</h3>
+    <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
+      <label>Phone<input type="tel" name="phone" maxlength="32" value="<%= biz.phone || '' %>"></label>
+      <label>Email (where leads land)<input type="email" name="email" maxlength="200" value="<%= biz.email || '' %>"></label>
+    </div>
+    <label>Website<input type="text" name="website" maxlength="300" value="<%= biz.website || '' %>" placeholder="https://yoursite.com"></label>
+    <label>Instagram handle<input type="text" name="instagram_handle" maxlength="60" value="<%= biz.instagram_handle || '' %>" placeholder="yourhandle (no @)"></label>
+
+    <button type="submit" class="btn btn-primary btn-lg" style="margin-top:24px">Save profile</button>
+  </form>
+</section>
+
+<%- include('../partials/footer') %>
diff --git a/views/partials/header.ejs b/views/partials/header.ejs
index 4c03d24..664659d 100644
--- a/views/partials/header.ejs
+++ b/views/partials/header.ejs
@@ -16,6 +16,11 @@
         <span class="t-light" aria-hidden="true">☼</span>
         <span class="t-dark" aria-hidden="true">☾</span>
       </button>
+      <% if (currentBusinessUser) { %>
+        <a href="/admin" class="btn btn-ghost btn-sm">Dashboard</a>
+      <% } else { %>
+        <a href="/admin/login" class="btn btn-ghost btn-sm">Business sign in</a>
+      <% } %>
     </div>
   </div>
 </header>
diff --git a/views/public/business.ejs b/views/public/business.ejs
index ccbda5f..95942cb 100644
--- a/views/public/business.ejs
+++ b/views/public/business.ejs
@@ -35,6 +35,7 @@
 
     <h2 id="contact" style="margin-top:32px">Send <%= business.business_name %> a message</h2>
     <form class="contact-form" action="/business/<%= business.slug %>/contact" method="post">
+      <input type="hidden" name="_csrf" value="<%= csrfToken %>">
       <p class="helper">Goes straight to the business — no middleman, no markup. They'll reply to your email.</p>
       <div class="row">
         <label>Your name<input type="text" name="name" required maxlength="120"></label>
@@ -62,10 +63,19 @@
     </div>
 
     <% if (business.claim_status === 'unclaimed') { %>
-      <div class="side-card" style="background:var(--bg);border-color:var(--brass)">
+      <div class="side-card" style="background:var(--bg);border-color:var(--brass)" id="claim">
         <h3 style="color:var(--brass)">Own this business?</h3>
-        <p style="font-size:13px;line-height:1.5;margin:0 0 10px">Claim your listing for free — control your description, photos, and how customers reach you.</p>
-        <a href="/for-businesses" class="btn btn-primary btn-sm">Claim this listing →</a>
+        <p style="font-size:13px;line-height:1.5;margin:0 0 10px">Claim your listing for free — control your description, photos, and how customers reach you. We'll email a confirmation link to verify ownership.</p>
+        <form action="/business/<%= business.slug %>/claim" method="post" style="display:flex;gap:6px;flex-wrap:wrap;margin:8px 0 0">
+          <input type="hidden" name="_csrf" value="<%= csrfToken %>">
+          <input type="email" name="email" required placeholder="you@<%= business.slug.split('-')[0] %>.com" style="flex:1;min-width:0">
+          <button type="submit" class="btn btn-primary btn-sm">Claim</button>
+        </form>
+      </div>
+    <% } else if (currentBusiness && currentBusiness.id === business.id) { %>
+      <div class="side-card" style="background:#dcfce7;border-color:#16a34a;color:#166534">
+        <h3 style="color:#166534">You own this listing</h3>
+        <p style="font-size:13px;line-height:1.5;margin:0 0 10px">Open your <a href="/admin">dashboard</a> to edit details or check leads.</p>
       </div>
     <% } %>
   </aside>
diff --git a/views/public/claim-complete.ejs b/views/public/claim-complete.ejs
new file mode 100644
index 0000000..2c33e46
--- /dev/null
+++ b/views/public/claim-complete.ejs
@@ -0,0 +1,33 @@
+<%- include('../partials/head', { title }) %>
+<%- include('../partials/header') %>
+
+<section class="long-form">
+  <% if (!ok) { %>
+    <p class="kicker">Link not valid</p>
+    <h1 class="display-sm">This claim link can't be used.</h1>
+    <p class="lede"><%
+      if (reason === 'expired') { %>The 24-hour window expired.<%
+      } else if (reason === 'already_used') { %>This link was already used to claim a listing — sign in below.<%
+      } else { %>The link is malformed or doesn't match a current claim.<%
+      } %></p>
+    <p style="margin-top:24px"><a href="/admin/login" class="btn btn-primary">Sign in</a> &nbsp; <a href="/find" class="btn btn-ghost">Browse the directory</a></p>
+  <% } else { %>
+    <p class="kicker">Claim · <%= business.business_name %></p>
+    <h1 class="display-sm">Set a password to take ownership.</h1>
+    <p class="lede">Welcome — once you set a password, you'll be signed in and dropped on your dashboard. You'll receive new leads at <strong><%= email %></strong>.</p>
+
+    <% if (error) { %>
+      <p class="callout" style="background:#fee2e2;border-left:3px solid #dc2626;padding:12px 16px;margin:16px 0;border-radius:4px"><%= error %></p>
+    <% } %>
+
+    <form method="post" action="/claim/<%= encodeURIComponent(token) %>" style="margin-top:24px;max-width:420px">
+      <input type="hidden" name="_csrf" value="<%= csrfToken %>">
+      <label>Email<input type="email" value="<%= email %>" disabled style="background:var(--bg-alt);color:var(--fg-muted)"></label>
+      <label>Password (10+ characters)<input type="password" name="password" required minlength="10" autocomplete="new-password"></label>
+      <label>Confirm password<input type="password" name="password_confirm" required minlength="10" autocomplete="new-password"></label>
+      <button type="submit" class="btn btn-primary btn-lg" style="margin-top:8px">Claim <%= business.business_name %> →</button>
+    </form>
+  <% } %>
+</section>
+
+<%- include('../partials/footer') %>
diff --git a/views/public/claim-result.ejs b/views/public/claim-result.ejs
new file mode 100644
index 0000000..625b992
--- /dev/null
+++ b/views/public/claim-result.ejs
@@ -0,0 +1,19 @@
+<%- include('../partials/head', { title }) %>
+<%- include('../partials/header') %>
+
+<section class="long-form" style="text-align:center">
+  <% if (ok) { %>
+    <p class="kicker">Check your email</p>
+    <h1 class="display-sm">We sent a claim link to <strong><%= email %></strong>.</h1>
+    <p class="lede" style="margin:0 auto">Click the link in that email within 24 hours to set a password and take ownership of <strong><%= business.business_name %></strong>'s Ventura Claw listing.</p>
+    <p class="muted" style="margin-top:24px;font-size:13px">Didn't get it? Check spam, or email <a href="mailto:info@venturaclaw.com">info@venturaclaw.com</a>.</p>
+    <a href="/business/<%= business.slug %>" class="btn btn-ghost btn-sm" style="margin-top:24px">← Back to <%= business.business_name %></a>
+  <% } else { %>
+    <p class="kicker">Almost there</p>
+    <h1 class="display-sm">Email needed</h1>
+    <p class="lede" style="margin:0 auto"><%= error %></p>
+    <a href="/business/<%= business.slug %>#claim" class="btn btn-primary" style="margin-top:16px">← Try again</a>
+  <% } %>
+</section>
+
+<%- include('../partials/footer') %>

← 319e023 v0.2 #4: Stripe subscription billing — products, prices, web  ·  back to Ventura Claw Leads  ·  yolo tick 1: JSON-LD LocalBusiness + ItemList structured dat 520e445 →