[object Object]

← back to Ventura Claw Leads

yolo tick 11: admin invite-teammate flow — multi-user per business

2c5302b843efe265e7e22ac0cb503dae96c8e58e · 2026-05-06 19:27:24 -0700 · Steve Abrams

DB:
- migrations/007_business_invitations.sql: business_invitations table —
  HMAC token, business_id FK, recipient email, invited_by_user_id, 7-day
  expires_at, consumed_at, ip_hash. Partial index on active.
- POST-DEPLOY GRANT (always required when sudo -u postgres applies a
  migration; vcl needs perms on the new table+sequence):
    GRANT ALL ON business_invitations TO vcl;
    GRANT USAGE,SELECT,UPDATE ON SEQUENCE business_invitations_id_seq TO vcl;

Auth lib:
- lib/auth.js: issueInviteToken / consumeInviteToken / markInviteConsumed
  alongside the existing claim-token helpers. 7-day TTL (vs 24h for claim).

Admin routes:
- GET /admin/team: list active business_users + pending invitations,
  flash messages for invited/removed/error states.
- POST /admin/team/invite: validate email, idempotent on existing member,
  mint token, send transactional email via Purelymail SMTP, audit in prod.
- POST /admin/team/invite/:id/cancel + /team/:id/remove (with last-user +
  self-remove guards).

Public routes:
- GET/POST /accept-invite/:token: set-password form, bcrypt, session-login,
  redirect to dashboard with welcome flash.

Views: admin/team.ejs, public/accept-invite.ejs, dashboard.ejs invite link.

Live test 2026-05-06: /admin/team → 302 auth-redirect, /admin/team/invite
POST → 403 CSRF, /accept-invite/<garbage> → 400 invalid (post-GRANT). 46/46
tests pass.

Files touched

Diff

commit 2c5302b843efe265e7e22ac0cb503dae96c8e58e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed May 6 19:27:24 2026 -0700

    yolo tick 11: admin invite-teammate flow — multi-user per business
    
    DB:
    - migrations/007_business_invitations.sql: business_invitations table —
      HMAC token, business_id FK, recipient email, invited_by_user_id, 7-day
      expires_at, consumed_at, ip_hash. Partial index on active.
    - POST-DEPLOY GRANT (always required when sudo -u postgres applies a
      migration; vcl needs perms on the new table+sequence):
        GRANT ALL ON business_invitations TO vcl;
        GRANT USAGE,SELECT,UPDATE ON SEQUENCE business_invitations_id_seq TO vcl;
    
    Auth lib:
    - lib/auth.js: issueInviteToken / consumeInviteToken / markInviteConsumed
      alongside the existing claim-token helpers. 7-day TTL (vs 24h for claim).
    
    Admin routes:
    - GET /admin/team: list active business_users + pending invitations,
      flash messages for invited/removed/error states.
    - POST /admin/team/invite: validate email, idempotent on existing member,
      mint token, send transactional email via Purelymail SMTP, audit in prod.
    - POST /admin/team/invite/:id/cancel + /team/:id/remove (with last-user +
      self-remove guards).
    
    Public routes:
    - GET/POST /accept-invite/:token: set-password form, bcrypt, session-login,
      redirect to dashboard with welcome flash.
    
    Views: admin/team.ejs, public/accept-invite.ejs, dashboard.ejs invite link.
    
    Live test 2026-05-06: /admin/team → 302 auth-redirect, /admin/team/invite
    POST → 403 CSRF, /accept-invite/<garbage> → 400 invalid (post-GRANT). 46/46
    tests pass.
---
 db/migrations/007_business_invitations.sql |  19 ++++++
 lib/auth.js                                |  39 ++++++++++-
 routes/admin.js                            | 103 +++++++++++++++++++++++++++++
 routes/claim.js                            |  55 +++++++++++++++
 views/admin/dashboard.ejs                  |   1 +
 views/admin/team.ejs                       |  85 ++++++++++++++++++++++++
 views/public/accept-invite.ejs             |  33 +++++++++
 7 files changed, 334 insertions(+), 1 deletion(-)

diff --git a/db/migrations/007_business_invitations.sql b/db/migrations/007_business_invitations.sql
new file mode 100644
index 0000000..ef4f7f5
--- /dev/null
+++ b/db/migrations/007_business_invitations.sql
@@ -0,0 +1,19 @@
+-- Multi-user-per-business via invite-teammate flow.
+-- The existing schema (003_business_users_and_claims.sql) already has
+-- UNIQUE (business_id, email) on business_users so multiple users per
+-- business is allowed at the table level — we just lacked a flow to
+-- create the second+ user. This migration adds that flow.
+
+CREATE TABLE IF NOT EXISTS business_invitations (
+  id              BIGSERIAL PRIMARY KEY,
+  token           TEXT UNIQUE NOT NULL,                   -- HMAC, 32 chars
+  business_id     BIGINT NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
+  email           TEXT NOT NULL,                          -- recipient
+  invited_by_user_id BIGINT NOT NULL REFERENCES business_users(id) ON DELETE SET NULL,
+  issued_at       TIMESTAMPTZ NOT NULL DEFAULT now(),
+  expires_at      TIMESTAMPTZ NOT NULL,
+  consumed_at     TIMESTAMPTZ,
+  ip_hash         TEXT
+);
+CREATE INDEX IF NOT EXISTS business_invitations_business_idx ON business_invitations (business_id);
+CREATE INDEX IF NOT EXISTS business_invitations_active_idx ON business_invitations (token) WHERE consumed_at IS NULL;
diff --git a/lib/auth.js b/lib/auth.js
index 4344325..91c14a0 100644
--- a/lib/auth.js
+++ b/lib/auth.js
@@ -126,9 +126,46 @@ 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
+  issueClaimToken, consumeClaimToken, markClaimConsumed,
+  issueInviteToken, consumeInviteToken, markInviteConsumed
 };
diff --git a/routes/admin.js b/routes/admin.js
index 7081ae2..0174aab 100644
--- a/routes/admin.js
+++ b/routes/admin.js
@@ -180,6 +180,109 @@ router.get('/leads.csv', async (req, res, next) => {
   } catch (err) { next(err); }
 });
 
+// ─── Team / multi-user per business ────────────────────────────────────
+const compliance = require('../lib/compliance');
+const { sendEmail } = require('../lib/email');
+const crypto = require('node:crypto');
+const PUBLIC_URL_RAW = (process.env.PUBLIC_URL || 'https://leads.venturaclaw.com').replace(/\/+$/, '');
+
+router.get('/team', async (req, res, next) => {
+  try {
+    const bizId = res.locals.currentBusiness.id;
+    const team = await db.many(`
+      SELECT id, email, display_name, last_login_at, created_at,
+             (id = $2) AS is_self
+        FROM business_users WHERE business_id = $1
+       ORDER BY created_at ASC
+    `, [bizId, res.locals.currentBusinessUser.id]);
+    const pendingInvites = await db.many(`
+      SELECT id, email, issued_at, expires_at
+        FROM business_invitations
+       WHERE business_id = $1 AND consumed_at IS NULL AND expires_at > now()
+       ORDER BY issued_at DESC
+    `, [bizId]);
+    res.render('admin/team', {
+      title: 'Team · Ventura Claw',
+      team, pendingInvites,
+      flash: req.query
+    });
+  } catch (err) { next(err); }
+});
+
+router.post('/team/invite', async (req, res, next) => {
+  try {
+    const bizId = res.locals.currentBusiness.id;
+    const inviterId = res.locals.currentBusinessUser.id;
+    const email = auth.normalizeEmail(req.body.email);
+    if (!auth.isEmailShape(email)) return res.redirect('/admin/team?error=bad_email');
+
+    // Already a team member? Idempotent — silent ok.
+    const existing = await db.one(
+      `SELECT id FROM business_users WHERE business_id = $1 AND email = $2`,
+      [bizId, email]
+    );
+    if (existing) return res.redirect('/admin/team?error=already_member');
+
+    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.issueInviteToken({
+      businessId: bizId, email, invitedByUserId: inviterId, ipHash
+    });
+
+    const link = `${PUBLIC_URL_RAW}/accept-invite/${encodeURIComponent(token)}`;
+    const biz = res.locals.currentBusiness;
+    const inviter = res.locals.currentBusinessUser;
+    const subject = `${inviter.display_name || inviter.email} invited you to manage ${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">Team invitation</p>
+  <h1 style="font-size:24px;font-weight:400;margin:0 0 12px">You've been invited to manage ${escapeHtml(biz.business_name)}</h1>
+  <p style="font-size:15px;line-height:1.55"><strong>${escapeHtml(inviter.display_name || inviter.email)}</strong> added you as a team member on Ventura Claw. Once you accept, you'll see the same dashboard, lead inbox, and profile editor for <strong>${escapeHtml(biz.business_name)}</strong>.</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">Accept invitation →</a></p>
+  <p style="font-size:13px;color:#666;line-height:1.55">Link valid for 7 days; works once.</p>
+${process.env.NODE_ENV === 'production' ? compliance.complianceFooter({ campaign: 'team_invite', unsubscribeUrl: null }) : ''}
+</div>`;
+    await sendEmail({ to: email, subject, html, text: `${subject}\n\n${link}\n\n(link expires in 7 days)` });
+    if (process.env.NODE_ENV === 'production') {
+      await compliance.recordAudit({
+        channel: 'email', campaign: 'team_invite', recipient: email, businessId: bizId,
+        decision: 'sent', subject, messageId: null, payload: { token_prefix: token.slice(0, 8) }
+      });
+    }
+    res.redirect('/admin/team?invited=' + encodeURIComponent(email));
+  } catch (err) { next(err); }
+});
+
+router.post('/team/invite/:id(\\d+)/cancel', async (req, res, next) => {
+  try {
+    await db.query(
+      `DELETE FROM business_invitations
+        WHERE id = $1 AND business_id = $2 AND consumed_at IS NULL`,
+      [parseInt(req.params.id, 10), res.locals.currentBusiness.id]
+    );
+    res.redirect('/admin/team');
+  } catch (err) { next(err); }
+});
+
+router.post('/team/:id(\\d+)/remove', async (req, res, next) => {
+  try {
+    const bizId = res.locals.currentBusiness.id;
+    const targetId = parseInt(req.params.id, 10);
+    const selfId = res.locals.currentBusinessUser.id;
+    if (targetId === selfId) return res.redirect('/admin/team?error=self_remove');
+    // Ensure at least one user remains (count after removal).
+    const others = await db.one(
+      `SELECT COUNT(*)::int AS n FROM business_users WHERE business_id = $1 AND id != $2`,
+      [bizId, targetId]
+    );
+    if (others.n === 0) return res.redirect('/admin/team?error=last_user');
+    await db.query(`DELETE FROM business_users WHERE id = $1 AND business_id = $2`, [targetId, bizId]);
+    res.redirect('/admin/team?removed=1');
+  } catch (err) { next(err); }
+});
+
+function escapeHtml(s) { return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
+
 // ─── Billing ───────────────────────────────────────────────────────────
 router.get('/billing', (req, res) => {
   res.render('admin/billing', {
diff --git a/routes/claim.js b/routes/claim.js
index 1cfb72f..f28e64c 100644
--- a/routes/claim.js
+++ b/routes/claim.js
@@ -142,6 +142,61 @@ router.post('/claim/:token', async (req, res, next) => {
   } catch (err) { next(err); }
 });
 
+// ─── Team-invitation acceptance (multi-user per business) ──────────────
+router.get('/accept-invite/:token', async (req, res, next) => {
+  try {
+    const r = await auth.consumeInviteToken(req.params.token);
+    if (!r.ok) {
+      return res.status(400).render('public/accept-invite', {
+        title: 'Invitation not valid', invite: null, ok: false, reason: r.reason
+      });
+    }
+    res.render('public/accept-invite', {
+      title: `Join ${r.invite.business_name}`,
+      invite: r.invite, ok: true, token: req.params.token, error: null
+    });
+  } catch (err) { next(err); }
+});
+
+router.post('/accept-invite/:token', async (req, res, next) => {
+  try {
+    const r = await auth.consumeInviteToken(req.params.token);
+    if (!r.ok) {
+      return res.status(400).render('public/accept-invite', {
+        title: 'Invitation not valid', invite: 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/accept-invite', {
+        title: 'Password too short', invite: r.invite, ok: true, token: req.params.token,
+        error: 'Pick a password with at least 10 characters.'
+      });
+    }
+    if (password !== passwordConfirm) {
+      return res.status(400).render('public/accept-invite', {
+        title: 'Passwords don\'t match', invite: r.invite, ok: true, token: req.params.token,
+        error: 'The two password fields must match.'
+      });
+    }
+
+    await auth.markInviteConsumed(req.params.token);
+    const passwordHash = await auth.hashPassword(password);
+    const user = await auth.createUser({
+      email: r.invite.email, businessId: r.invite.business_id,
+      passwordHash, displayName: r.invite.email.split('@')[0],
+      emailVerified: true
+    });
+    if (user && !user.password_hash) {
+      await auth.setPassword(user.id, password);
+    }
+    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;');
 }
diff --git a/views/admin/dashboard.ejs b/views/admin/dashboard.ejs
index 01c74e4..6225f88 100644
--- a/views/admin/dashboard.ejs
+++ b/views/admin/dashboard.ejs
@@ -65,6 +65,7 @@
   <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="/admin/team">Invite a teammate</a> — let multiple people respond to leads.</li>
     <li><a href="/business/<%= currentBusiness.slug %>" target="_blank">View your live profile ↗</a></li>
   </ul>
 </section>
diff --git a/views/admin/team.ejs b/views/admin/team.ejs
new file mode 100644
index 0000000..1cb9ebf
--- /dev/null
+++ b/views/admin/team.ejs
@@ -0,0 +1,85 @@
+<%- include('../partials/head', { title }) %>
+<%- include('../partials/header') %>
+
+<section class="long-form" style="max-width:720px">
+  <p class="kicker">Team</p>
+  <h1 class="display-sm">Manage who can edit <%= currentBusiness.business_name %>.</h1>
+  <p class="lede">Add team members so multiple people can respond to leads. Each member gets the same dashboard.</p>
+
+  <% if (flash.invited) { %><p class="callout" style="background:#dcfce7;border-left:3px solid #16a34a;color:#166534;padding:12px 16px;margin:16px 0;border-radius:4px">Invitation sent to <strong><%= flash.invited %></strong>. They have 7 days to accept.</p><% } %>
+  <% if (flash.removed) { %><p class="callout" style="background:#dcfce7;border-left:3px solid #16a34a;color:#166534;padding:12px 16px;margin:16px 0;border-radius:4px">Team member removed.</p><% } %>
+  <% if (flash.error === 'bad_email') { %><p class="callout" style="background:#fee2e2;border-left:3px solid #dc2626;padding:12px 16px;margin:16px 0;border-radius:4px">That doesn't look like a valid email address.</p><% } %>
+  <% if (flash.error === 'already_member') { %><p class="callout" style="background:#fee2e2;border-left:3px solid #dc2626;padding:12px 16px;margin:16px 0;border-radius:4px">That email is already a team member.</p><% } %>
+  <% if (flash.error === 'last_user') { %><p class="callout" style="background:#fee2e2;border-left:3px solid #dc2626;padding:12px 16px;margin:16px 0;border-radius:4px">Can't remove the last team member — invite someone else first.</p><% } %>
+  <% if (flash.error === 'self_remove') { %><p class="callout" style="background:#fee2e2;border-left:3px solid #dc2626;padding:12px 16px;margin:16px 0;border-radius:4px">Can't remove yourself. Have another team member do it, or contact support.</p><% } %>
+
+  <h2 style="margin-top:32px">Active members</h2>
+  <table style="width:100%;border-collapse:collapse;margin:16px 0;font-size:14px">
+    <thead>
+      <tr style="border-bottom:1px solid var(--border);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">Email</th>
+        <th style="padding:10px 8px;font-size:11px;letter-spacing:0.08em;text-transform:uppercase;color:var(--fg-muted);font-weight:500">Joined</th>
+        <th style="padding:10px 8px;font-size:11px;letter-spacing:0.08em;text-transform:uppercase;color:var(--fg-muted);font-weight:500">Last sign-in</th>
+        <th style="padding:10px 8px;font-size:11px;letter-spacing:0.08em;text-transform:uppercase;color:var(--fg-muted);font-weight:500"></th>
+      </tr>
+    </thead>
+    <tbody>
+      <% team.forEach(function(u){ %>
+        <tr style="border-bottom:1px solid var(--border)">
+          <td style="padding:12px 8px"><%= u.email %><% if (u.is_self) { %> <span class="muted" style="font-size:11px">(you)</span><% } %></td>
+          <td style="padding:12px 8px;color:var(--fg-muted)"><%= new Date(u.created_at).toLocaleDateString('en-US',{ month:'short', day:'numeric', year:'numeric' }) %></td>
+          <td style="padding:12px 8px;color:var(--fg-muted)"><%= u.last_login_at ? new Date(u.last_login_at).toLocaleDateString('en-US',{ month:'short', day:'numeric' }) : '—' %></td>
+          <td style="padding:12px 8px;text-align:right">
+            <% if (!u.is_self) { %>
+              <form method="post" action="/admin/team/<%= u.id %>/remove" style="margin:0" onsubmit="return confirm('Remove <%= u.email %>?');">
+                <input type="hidden" name="_csrf" value="<%= csrfToken %>">
+                <button type="submit" class="btn btn-ghost btn-sm" style="font-size:11px;padding:4px 10px">Remove</button>
+              </form>
+            <% } %>
+          </td>
+        </tr>
+      <% }); %>
+    </tbody>
+  </table>
+
+  <% if (pendingInvites.length > 0) { %>
+    <h2 style="margin-top:32px">Pending invitations</h2>
+    <table style="width:100%;border-collapse:collapse;margin:16px 0;font-size:14px">
+      <thead>
+        <tr style="border-bottom:1px solid var(--border);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">Email</th>
+          <th style="padding:10px 8px;font-size:11px;letter-spacing:0.08em;text-transform:uppercase;color:var(--fg-muted);font-weight:500">Sent</th>
+          <th style="padding:10px 8px;font-size:11px;letter-spacing:0.08em;text-transform:uppercase;color:var(--fg-muted);font-weight:500">Expires</th>
+          <th style="padding:10px 8px"></th>
+        </tr>
+      </thead>
+      <tbody>
+        <% pendingInvites.forEach(function(inv){ %>
+          <tr style="border-bottom:1px solid var(--border)">
+            <td style="padding:12px 8px"><%= inv.email %></td>
+            <td style="padding:12px 8px;color:var(--fg-muted)"><%= new Date(inv.issued_at).toLocaleDateString('en-US',{ month:'short', day:'numeric' }) %></td>
+            <td style="padding:12px 8px;color:var(--fg-muted)"><%= new Date(inv.expires_at).toLocaleDateString('en-US',{ month:'short', day:'numeric' }) %></td>
+            <td style="padding:12px 8px;text-align:right">
+              <form method="post" action="/admin/team/invite/<%= inv.id %>/cancel" style="margin:0">
+                <input type="hidden" name="_csrf" value="<%= csrfToken %>">
+                <button type="submit" class="btn btn-ghost btn-sm" style="font-size:11px;padding:4px 10px">Cancel</button>
+              </form>
+            </td>
+          </tr>
+        <% }); %>
+      </tbody>
+    </table>
+  <% } %>
+
+  <h2 style="margin-top:32px">Invite a teammate</h2>
+  <form method="post" action="/admin/team/invite" style="display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end">
+    <input type="hidden" name="_csrf" value="<%= csrfToken %>">
+    <label style="flex:1;min-width:240px">Email
+      <input type="email" name="email" required placeholder="teammate@example.com">
+    </label>
+    <button type="submit" class="btn btn-primary">Send invitation</button>
+  </form>
+  <p class="muted" style="margin-top:12px;font-size:12px">They'll get a magic link valid for 7 days. Once they pick a password, they'll see the same dashboard you see now.</p>
+</section>
+
+<%- include('../partials/footer') %>
diff --git a/views/public/accept-invite.ejs b/views/public/accept-invite.ejs
new file mode 100644
index 0000000..3aee16d
--- /dev/null
+++ b/views/public/accept-invite.ejs
@@ -0,0 +1,33 @@
+<%- include('../partials/head', { title }) %>
+<%- include('../partials/header') %>
+
+<section class="long-form">
+  <% if (!ok) { %>
+    <p class="kicker">Invitation not valid</p>
+    <h1 class="display-sm">This invite can't be used.</h1>
+    <p class="lede"><%
+      if (reason === 'expired') { %>The 7-day window expired. Ask whoever invited you to send a new invitation.<%
+      } else if (reason === 'already_used') { %>This invitation has already been accepted — sign in below.<%
+      } else { %>The link is malformed or doesn't match a current invitation.<%
+      } %></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">Invitation · <%= invite.business_name %></p>
+    <h1 class="display-sm">Set a password to join the team.</h1>
+    <p class="lede"><strong><%= invite.inviter_name || invite.inviter_email %></strong> invited you to manage <strong><%= invite.business_name %></strong> on Ventura Claw. Once you set a password, you'll be signed in and dropped on the dashboard alongside the existing team.</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="/accept-invite/<%= encodeURIComponent(token) %>" style="margin-top:24px;max-width:420px">
+      <input type="hidden" name="_csrf" value="<%= csrfToken %>">
+      <label>Email<input type="email" value="<%= invite.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">Join <%= invite.business_name %> →</button>
+    </form>
+  <% } %>
+</section>
+
+<%- include('../partials/footer') %>

← f3a4936 yolo tick 10: home-page social proof + /admin/leads date-ran  ·  back to Ventura Claw Leads  ·  yolo tick 12: 30-day lead-trend sparkline on admin dashboard abf7235 →